From ac4406b86019f628bf77d3737889ada63598b39b Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 17:37:53 +0200 Subject: [PATCH 001/188] docs: design Connect Share Fabric mod --- .../2026-07-30-connect-share-mod-design.md | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-connect-share-mod-design.md 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..e29ab01fc --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -0,0 +1,396 @@ +# Connect Share Mod Design + +**Date:** 2026-07-30 +**Status:** Architecture approved; written-spec review pending +**Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) + +## 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 a temporary Minekube Connect address. 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 Connect** pause-menu + action; they do not press Minecraft's Open to LAN button. +- No listener is exposed on a LAN or WAN interface. +- Every share creates a new temporary Connect address. Stopping the share or + leaving the world makes that address unreachable, and a later share receives + a different address. +- Connect authenticates vanilla guests at the edge. The mod preserves the + resulting verified player context when it injects the session locally. +- The host must approve every new verified Minecraft UUID. An approval is + remembered only for the current share session. +- 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. +- Offline-mode Java accounts are not supported in the first release. +- 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 a temporary Connect hostname. +3. Reuse Connect's authenticated session 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. + +## 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 +- Offline/cracked-account support +- 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. + +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` +- 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 verified session 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 a verified +login until `AdmissionController` accepts or rejects it. + +### ConnectShareIngress + +Creates a fresh random endpoint name and endpoint token for each share. It +starts the existing Connect watch/libp2p connector runtime against the local +server address and stops it with the share. + +The first implementation treats the endpoint as ephemeral by lifetime: + +- credentials live only in the active share object; +- credentials are never written to the normal persistent plugin config; +- a later share never reuses them; +- stopping the watch/registration makes the address unreachable. + +The endpoint record may remain reserved in the Connect control plane after it +goes offline. Control-plane deletion or a first-class expiring lease is an +additive service improvement and is not required for the address to be +unreachable or non-reusable by this mod. + +Connect session proposals remain pending while the host approves the verified +profile. Denial, timeout, world shutdown, and capacity exhaustion reject the +proposal before a local tunnel is opened. + +### 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. + +Every share creates an ephemeral libp2p identity so separate shares cannot be +correlated by a stable peer ID. 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 ordinary Minecraft login bytes into the same local +Minecraft initializer. Minecraft performs normal online-mode authentication +for this path. The host admission hook runs after the profile is authenticated +and before the player enters the world. + +### ShareInviteCodec + +A copied invitation is a versioned URI: + +```text +minekube://share/{base64url-cbor-payload} +``` + +The signed payload contains: + +- wire protocol version; +- share ID; +- expiry; +- temporary Connect hostname when Connect is available; +- ephemeral host peer ID; +- direct candidates only when the host enabled internet P2P; +- an unguessable per-share capability; +- the host peer signature over every preceding field. + +The capability authorizes requesting admission; it never bypasses host +approval or Minecraft account authentication. 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 Share screen. Pasting the URI into Minecraft's +Direct Connection field is detected by the mod and routed through the same +parser. + +### AdmissionController + +Admission is keyed by authenticated Minecraft UUID, not username, IP address, +or libp2p peer ID. + +For a new UUID, the controller: + +1. creates one pending request; +2. shows the host the verified name, UUID, and ingress type; +3. offers **Allow** and **Deny** actions; +4. expires the request after 30 seconds; +5. remembers an allowed UUID until this share stops. + +Duplicate requests for the same UUID share one decision. At most 16 requests +may be pending. 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 Connect**. The setup screen shows: + +- game mode; +- allow-cheats option; +- maximum guests, default 8 and range 1–16; +- **Allow direct internet connections**, off by default, with an IP-disclosure + warning; +- **Start Sharing**. + +While active, the screen shows: + +- temporary 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**. + +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 temporary hostname. Modded +guests can use **Join Share** or paste a `minekube://share/` invitation. + +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. + +## Security and Privacy + +- Connect identity is accepted only from a session context produced by the + managed Connect ingress. +- Direct sessions complete normal Mojang/Microsoft online-mode authentication + in the integrated server before admission. +- Every ingress requires host approval for a previously unseen UUID. +- Approvals, endpoint credentials, share capabilities, and ephemeral peer + identities die with the share. +- 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. +- 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 temporary endpoint, the UI shows the + sanitized watch-service reason and offers retry with fresh credentials. +- 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; +- temporary credential non-reuse; +- admission allow, deny, duplicate, timeout, capacity, 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; +- direct stream reaches the vanilla child initializer without a public bind; +- 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 a new UUID and verify approval resets after restart. +4. Join automatically between two modded clients on one LAN with Connect + unavailable. +5. Verify internet direct is never attempted without confirmation on both + peers. +6. Verify successful internet direct where NAT permits it. +7. Verify a failed internet-direct attempt falls back to Connect. +8. Stop sharing and prove the old hostname and invitation no longer reach the + world. +9. 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 temporary 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. From 69e66b676d6baf2507b08406cd9eb84050bf5dfd Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 17:41:58 +0200 Subject: [PATCH 002/188] docs: persist Connect Share endpoint identity --- .../2026-07-30-connect-share-mod-design.md | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) 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 index e29ab01fc..e3372b0c5 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -8,9 +8,9 @@ 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 a temporary Minekube Connect address. Guests -with the mod can additionally use a direct libp2p connection when both sides -permit it. +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 @@ -26,9 +26,12 @@ logic is written in Kotlin and shared across both versions. - The host starts sharing from a dedicated **Share with Connect** pause-menu action; they do not press Minecraft's Open to LAN button. - No listener is exposed on a LAN or WAN interface. -- Every share creates a new temporary Connect address. Stopping the share or - leaving the world makes that address unreachable, and a later share receives - a different address. +- 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. +- Stopping the share or leaving the world makes the stable endpoint + unreachable until the host explicitly starts another share. - Connect authenticates vanilla guests at the edge. The mod preserves the resulting verified player context when it injects the session locally. - The host must approve every new verified Minecraft UUID. An approval is @@ -48,7 +51,8 @@ logic is written in Kotlin and shared across both versions. 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 a temporary Connect hostname. +2. Let an unmodified Java client join through the host's Connect hostname + while sharing is active. 3. Reuse Connect's authenticated session and tunnel semantics instead of creating a parallel public ingress service. 4. Let two modded clients connect directly on the same LAN without Connect. @@ -154,21 +158,32 @@ login until `AdmissionController` accepts or rejects it. ### ConnectShareIngress -Creates a fresh random endpoint name and endpoint token for each share. It -starts the existing Connect watch/libp2p connector runtime against the local -server address and stops it with the share. +Loads or creates one persistent Connect identity for the Minecraft +installation: -The first implementation treats the endpoint as ephemeral by lifetime: - -- credentials live only in the active share object; -- credentials are never written to the normal persistent plugin config; -- a later share never reuses them; -- stopping the watch/registration makes the address unreachable. +```text +config/minekube-connect-share/config.json +config/minekube-connect-share/token.json +``` -The endpoint record may remain reserved in the Connect control plane after it -goes offline. Control-plane deletion or a first-class expiring lease is an -additive service improvement and is not required for the address to be -unreachable or non-reusable by this mod. +`config.json` stores the endpoint name and non-secret user settings. +`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. `CONNECT_SHARE_ENDPOINT` and `CONNECT_SHARE_TOKEN` override +the files for development and managed launchers without colliding with a +server plugin in the same process. + +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 Connect identity**. Resetting warns that it creates +a new endpoint and invalidates the old local identity. Connect session proposals remain pending while the host approves the verified profile. Denial, timeout, world shutdown, and capacity exhaustion reject the @@ -206,7 +221,7 @@ The signed payload contains: - wire protocol version; - share ID; - expiry; -- temporary Connect hostname when Connect is available; +- persistent Connect hostname when Connect is available; - ephemeral host peer ID; - direct candidates only when the host enabled internet P2P; - an unguessable per-share capability; @@ -270,7 +285,7 @@ The pause menu contains **Share with Connect**. The setup screen shows: While active, the screen shows: -- temporary Connect address and copy button; +- Connect address and copy button; - copyable full mod invitation; - Connect, LAN direct, and internet direct status separately; - connected and approved players; @@ -282,8 +297,10 @@ the screen does not stop sharing. ### Guest -Vanilla guests add or directly connect to the temporary hostname. Modded -guests can use **Join Share** or paste a `minekube://share/` invitation. +Vanilla guests add or directly connect to the host's Connect hostname. Modded +guests can use **Join Share** or paste a `minekube://share/` invitation. The +hostname is stable and is not treated as a secret; verified identity 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 @@ -296,8 +313,10 @@ will learn each other's IP address. - Direct sessions complete normal Mojang/Microsoft online-mode authentication in the integrated server before admission. - Every ingress requires host approval for a previously unseen UUID. -- Approvals, endpoint credentials, share capabilities, and ephemeral peer - identities die with the share. +- Approvals, share capabilities, and ephemeral peer identities die with the + share. The 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. @@ -315,8 +334,9 @@ will learn each other's IP address. - 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 temporary endpoint, the UI shows the - sanitized watch-service reason and offers retry with fresh credentials. +- 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. @@ -326,7 +346,8 @@ will learn each other's IP address. ### Common unit tests - state-machine transitions and idempotent cleanup; -- temporary credential non-reuse; +- persistent endpoint creation, reload, environment override, redaction, + cross-world reuse, and explicit-only reset; - admission allow, deny, duplicate, timeout, capacity, and share reset; - invitation round-trip, signature, expiry, version, capability, and redaction; - transport order, privacy opt-in, timeouts, and Connect fallback. @@ -374,9 +395,10 @@ Before calling the feature complete: peers. 6. Verify successful internet direct where NAT permits it. 7. Verify a failed internet-direct attempt falls back to Connect. -8. Stop sharing and prove the old hostname and invitation no longer reach the - world. -9. Confirm no LAN/WAN Minecraft listener is reachable from another machine. +8. Stop sharing and prove the hostname no longer reaches the world. +9. Start a different world and prove the same endpoint name and token are + reused while the old signed invitation is rejected. +10. Confirm no LAN/WAN Minecraft listener is reachable from another machine. ## Delivery Sequence @@ -385,7 +407,7 @@ final scope: 1. Kotlin/Fabric multi-version build, share state, admission, invitations, and version adapters. -2. Integrated-server local bridge and temporary Connect ingress for vanilla +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. From a2c203893bd8f4a3be49b615c6fd6924b035ac2c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 17:51:12 +0200 Subject: [PATCH 003/188] docs: support imported and offline Share identities --- .../2026-07-30-connect-share-mod-design.md | 193 +++++++++++++----- 1 file changed, 143 insertions(+), 50 deletions(-) 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 index e3372b0c5..0fd5cc0fa 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -1,7 +1,7 @@ # Connect Share Mod Design **Date:** 2026-07-30 -**Status:** Architecture approved; written-spec review pending +**Status:** Approved for implementation **Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) ## Summary @@ -30,12 +30,19 @@ logic is written in Kotlin and shared across both versions. 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 authenticates vanilla guests at the edge. The mod preserves the - resulting verified player context when it injects the session locally. -- The host must approve every new verified Minecraft UUID. An approval is - remembered only for the current share session. +- 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 @@ -43,7 +50,6 @@ logic is written in Kotlin and shared across both versions. - 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. -- Offline-mode Java accounts are not supported in the first release. - Fabric builds are published for Minecraft 1.21.11 and 26.2. NeoForge is a later adapter, not part of this implementation. @@ -53,13 +59,17 @@ logic is written in Kotlin and shared across both versions. 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 authenticated session and tunnel semantics instead of - creating a parallel public ingress service. +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, public + hostname, and attached custom domains 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 @@ -68,7 +78,6 @@ logic is written in Kotlin and shared across both versions. - A friend graph, social network, or persistent invitations - UPnP-based public Minecraft TCP listeners - An independent libp2p relay network -- Offline/cracked-account support - Bedrock guest support in the first mod release - Voice chat tunneling - NeoForge, Forge, or Quilt artifacts in the first release @@ -145,16 +154,19 @@ 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 verified session into -the accepted local channel. +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 a verified -login until `AdmissionController` accepts or rejects it. +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 @@ -170,9 +182,15 @@ config/minekube-connect-share/token.json `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. `CONNECT_SHARE_ENDPOINT` and `CONNECT_SHARE_TOKEN` override -the files for development and managed launchers without colliding with a -server plugin in the same process. +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 @@ -185,9 +203,33 @@ rotation. The UI explains the mismatch and lets the user restore the token or explicitly choose **Reset Connect identity**. Resetting warns that it creates a new endpoint and invalidates the old local identity. -Connect session proposals remain pending while the host approves the verified -profile. Denial, timeout, world shutdown, and capacity exhaustion reject the -proposal before a local tunnel is opened. +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. + +Connect session proposals remain pending while the host approves the supplied +profile and its displayed trust level. The connector advertises support for +offline-mode players, as the Connect plugin can. Denial, timeout, world +shutdown, and capacity exhaustion reject the proposal before a local tunnel +is opened. ### DirectP2pIngress @@ -203,10 +245,18 @@ correlated by a stable peer ID. The direct service supports: - coordinated QUIC hole punching when candidate exchange is available; - no circuit-relay candidates outside the managed Connect path. -The direct stream carries ordinary Minecraft login bytes into the same local -Minecraft initializer. Minecraft performs normal online-mode authentication -for this path. The host admission hook runs after the profile is authenticated -and before the player enters the world. +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 @@ -228,9 +278,9 @@ The signed payload contains: - the host peer signature over every preceding field. The capability authorizes requesting admission; it never bypasses host -approval or Minecraft account authentication. Same-LAN discovery advertises -the share ID, protocol version, peer ID, and a short display name, but not the -internet capability or public candidates. +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 Share screen. Pasting the URI into Minecraft's @@ -239,20 +289,28 @@ parser. ### AdmissionController -Admission is keyed by authenticated Minecraft UUID, not username, IP address, -or libp2p peer ID. +Admission uses an explicit identity type: -For a new UUID, the controller: +```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 verified name, UUID, and ingress type; +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 UUID until this share stops. +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 UUID share one decision. At most 16 requests -may be pending. Excess requests are rejected. Denial and timeout are visible -to the guest without exposing internal errors. +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 @@ -292,6 +350,12 @@ While active, the screen shows: - pending approval cards; - **Stop Sharing**. +Connect identity settings show the endpoint name, credential source +(generated, imported, or environment), and a masked token status. They provide +**Import existing endpoint** and the separately warned **Reset Connect +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. @@ -299,20 +363,28 @@ the screen does not stop sharing. Vanilla guests add or directly connect to the host's Connect hostname. Modded guests can use **Join Share** or paste a `minekube://share/` invitation. The -hostname is stable and is not treated as a secret; verified identity and host -approval remain the authorization boundary. +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. +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. -- Direct sessions complete normal Mojang/Microsoft online-mode authentication - in the integrated server before admission. -- Every ingress requires host approval for a previously unseen UUID. +- 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. - Approvals, share capabilities, and ephemeral peer identities die with the share. The Connect endpoint name and token persist across shares. - The persistent endpoint token is stored separately from ordinary settings, @@ -322,7 +394,8 @@ will learn each other's IP address. 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. + share. Admission attempts are additionally bounded per Connect session or + ephemeral direct peer. - Malformed, expired, unsupported-version, incorrectly signed, or capability-mismatched invitations are rejected before dialing. @@ -348,14 +421,24 @@ will learn each other's IP address. - state-machine transitions and idempotent cleanup; - persistent endpoint creation, reload, environment override, redaction, cross-world reuse, and explicit-only reset; -- admission allow, deny, duplicate, timeout, capacity, and share 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; @@ -388,17 +471,27 @@ 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 a new UUID and verify approval resets after restart. -4. Join automatically between two modded clients on one LAN with Connect +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. -5. Verify internet direct is never attempted without confirmation on both +7. Verify internet direct is never attempted without confirmation on both peers. -6. Verify successful internet direct where NAT permits it. -7. Verify a failed internet-direct attempt falls back to Connect. -8. Stop sharing and prove the hostname no longer reaches the world. -9. Start a different world and prove the same endpoint name and token are +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 and token are reused while the old signed invitation is rejected. -10. Confirm no LAN/WAN Minecraft listener is reachable from another machine. +12. Import a dashboard-created endpoint and token, then prove its hostname and + attached dashboard configuration are 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 From c7d14cb9384a04cb0b6b6a06227e0a4b020b805a Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:06:04 +0200 Subject: [PATCH 004/188] docs: plan Connect Share singleplayer ingress --- .../2026-07-30-connect-share-singleplayer.md | 1303 +++++++++++++++++ .../2026-07-30-connect-share-mod-design.md | 14 +- 2 files changed, 1312 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md 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..bd9fe825e --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -0,0 +1,1303 @@ +# 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/libp2p versions. +- `.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: `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`, and `loomVersion`. + +- [ ] **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 +} +``` + +- [ ] **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 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 +``` + +- [ ] **Step 3: Keep plugin and Fabric conventions separate** + +In root `build.gradle.kts`, define: + +```kotlin +val fabricProjects = setOf( + projects.share.common, + projects.share.fabricCommon, + projects.share.fabric12111, + projects.share.fabric262, +).map { it.dependencyProject } +``` + +Apply the existing Java-11/Lombok/Shadow conventions only when `this !in fabricProjects`. 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. + +The `share/common` dependencies are: + +```kotlin +implementation(projects.core) +implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") +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("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 `Versions.fabricApi262Version`. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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)`. + +- [ ] **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)); +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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): CredentialValidation +} +sealed interface CredentialValidation { + data object Valid : CredentialValidation + data class Invalid(val safeMessage: String) : CredentialValidation +} +``` + +- [ ] **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`() +``` + +Use a deterministic `EndpointNameSource { "amber-fox" }` and token source returning `T-AAAAAAAAAAAAAAAAAAAA`. + +- [ ] **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`. + +- [ ] **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, + ): CredentialValidation + suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + validator: EndpointCredentialValidator, + ): CredentialValidation + suspend fun resetConfirmed(): EndpointIdentity +} +``` + +Use `config.json` with: + +```json +{"endpoint":"amber-fox","credentialSource":"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 `CredentialValidation.Valid`, then atomically replaces token first and config second while retaining backups until both moves succeed. Restore both backups when the second move fails. + +Before either move, write `identity-transaction.json` containing the old and +new endpoint names plus both backup file names. `currentOrCreate()` calls +`recoverInterruptedTransaction()` before reading identity files. When the +journal exists, restore both backups, or remove both partially created files +when no prior identity existed, then delete the journal. Delete backups and the +journal only after both final files are durable. A process crash during either +move therefore rolls back on the next load. + +- [ ] **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 `CredentialValidation.Invalid`; transport failure returns a safe network message. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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 } +``` + +- [ ] **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. + +- [ ] **Step 2: Run and observe failure** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.admission.AdmissionControllerTest +``` + +Expected: missing admission types. + +- [ ] **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. + +- [ ] **Step 4: Run tests** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.admission.AdmissionControllerTest +``` + +Expected: all seven cases pass. + +- [ ] **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(); +} +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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, +) +``` + +- [ ] **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`() +``` + +- [ ] **Step 2: Run and observe missing production types** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.ShareCoordinatorTest +``` + +Expected: compilation failure. + +- [ ] **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`. `stop` snapshots handles under the mutex, publishes `Stopping`, closes ingress, closes bridge, resets admission, then publishes `Idle`. Every close runs even when a previous close throws; aggregate failures into logs but keep UI messages sanitized. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. 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`. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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) +} +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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 Connect** when idle and **Connect Share** when active. + +The setup screen contains game mode, cheats, max guests default 8, and **Start Sharing**. + +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**; +- **Endpoint identity** link. + +The identity screen contains: + +- endpoint name; +- masked credential source; +- **Import existing endpoint**; +- endpoint field plus masked token field; +- `token.json` chooser; +- **Validate and save**; +- warned **Reset Connect 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 hostname/custom-domain configuration. +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-30-connect-share-mod-design.md b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md index 0fd5cc0fa..9b1339301 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -225,11 +225,15 @@ 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. -Connect session proposals remain pending while the host approves the supplied -profile and its displayed trust level. The connector advertises support for -offline-mode players, as the Connect plugin can. Denial, timeout, world -shutdown, and capacity exhaustion reject the proposal before a local tunnel -is opened. +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 From 6a60411975125788591309a36aa0e19d402bbf56 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:33:38 +0200 Subject: [PATCH 005/188] build: add multi-version Fabric Share modules --- build-logic/build.gradle.kts | 13 +-- build-logic/src/main/kotlin/Versions.kt | 10 ++- .../connect.base-conventions.gradle.kts | 2 +- .../connect.publish-conventions.gradle.kts | 3 +- .../connect.shadow-conventions.gradle.kts | 2 +- build-logic/src/main/kotlin/extensions.kt | 16 ++-- build.gradle.kts | 42 +++++---- core/build.gradle.kts | 15 ++-- .../minekube/connect/util/Constants.java.peb} | 6 +- .../2026-07-30-connect-share-singleplayer.md | 89 +++++++++++++------ .../2026-07-30-connect-share-mod-design.md | 9 ++ gradle.properties | 3 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 23 ++++- share/AGENTS.md | 66 ++++++++++++++ share/common/build.gradle.kts | 30 +++++++ .../com/minekube/connect/share/ShareBuild.kt | 6 ++ .../minekube/connect/share/BuildPinsTest.kt | 12 +++ share/fabric-1.21.11/build.gradle.kts | 51 +++++++++++ share/fabric-26.2/build.gradle.kts | 50 +++++++++++ share/fabric-common/build.gradle.kts | 33 +++++++ 21 files changed, 405 insertions(+), 78 deletions(-) rename core/src/main/{java/com/minekube/connect/util/Constants.java => java-templates/com/minekube/connect/util/Constants.java.peb} (89%) create mode 100644 share/AGENTS.md create mode 100644 share/common/build.gradle.kts create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareBuild.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/BuildPinsTest.kt create mode 100644 share/fabric-1.21.11/build.gradle.kts create mode 100644 share/fabric-26.2/build.gradle.kts create mode 100644 share/fabric-common/build.gradle.kts 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..75bbcb13f 100644 --- a/build-logic/src/main/kotlin/Versions.kt +++ b/build-logic/src/main/kotlin/Versions.kt @@ -40,8 +40,16 @@ 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 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..77b9c4fad 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" ) 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..41a15884f 100644 --- a/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts @@ -112,5 +112,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..9596b2df2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("connect.build-logic") id("io.freefair.lombok") version "8.6" 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/core/build.gradle.kts b/core/build.gradle.kts index 27ff74753..f504b90a0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -53,11 +53,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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index bd9fe825e..f8522e3a3 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -38,7 +38,8 @@ This plan is the independently testable singleplayer-through-Connect slice. It e - `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/libp2p versions. +- `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 @@ -98,6 +99,7 @@ This plan is the independently testable singleplayer-through-Connect slice. It e **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` @@ -110,9 +112,9 @@ This plan is the independently testable singleplayer-through-Connect slice. It e **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`, and `loomVersion`. +- 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`. -- [ ] **Step 1: Write the failing build-pin test** +- [x] **Step 1: Write the failing build-pin test** ```kotlin package com.minekube.connect.share @@ -140,7 +142,7 @@ object ShareBuild { } ``` -- [ ] **Step 2: Add the exact Gradle pins and project includes** +- [x] **Step 2: Add the exact Gradle pins and project includes** Add these constants to `Versions.kt`: @@ -152,6 +154,7 @@ 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" ``` @@ -160,8 +163,8 @@ Add `maven("https://maven.fabricmc.net/")` to dependency and plugin repositories ```kotlin include(":share:common") include(":share:fabric-common") -include(":share:fabric-1.21.11") -include(":share:fabric-26.2") +include(":share:fabric-1-21-11") +include(":share:fabric-26-2") ``` Set the wrapper URL exactly: @@ -170,26 +173,42 @@ Set the wrapper URL exactly: distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip ``` -- [ ] **Step 3: Keep plugin and Fabric conventions separate** +Give the combined Loom-remap and plugin-shadow build enough heap: -In root `build.gradle.kts`, define: +```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 fabricProjects = setOf( - projects.share.common, - projects.share.fabricCommon, - projects.share.fabric12111, - projects.share.fabric262, -).map { it.dependencyProject } +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 `this !in fabricProjects`. 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. +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") @@ -201,6 +220,9 @@ The `share/fabric-common` dependencies are: 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}") @@ -223,19 +245,28 @@ implementation(projects.share.common) implementation(projects.share.fabricCommon) ``` -The 26.2 block uses `minecraft("com.mojang:minecraft:26.2")`, no mappings dependency, and `Versions.fabricApi262Version`. +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. -- [ ] **Step 4: Run the new test and both empty mod builds** +- [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 +./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. -- [ ] **Step 5: Run the existing plugin build** +- [x] **Step 5: Run the existing plugin build** Run: @@ -245,7 +276,7 @@ Run: 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`. -- [ ] **Step 6: Commit** +- [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 @@ -913,7 +944,7 @@ git commit -m "feat: add embedded Fabric Connect ingress" Run: ```bash -./gradlew :share:fabric-1.21.11:genSources +./gradlew :share:fabric-1-21-11:genSources ``` Confirm the official mapped members used by this task exist: @@ -984,7 +1015,7 @@ For passthrough Connect sessions it lets vanilla resolve online/offline login, t Run: ```bash -./gradlew :share:fabric-1.21.11:test :share:fabric-1.21.11:runServer --args='nogui' +./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. @@ -1012,7 +1043,7 @@ git commit -m "feat: bridge Connect into 1.21.11 singleplayer" Run: ```bash -./gradlew :share:fabric-26.2:genSources +./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`. @@ -1040,7 +1071,7 @@ Repeat the explicit loopback, captured initializer, `LocalServerChannelWrapper`, 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 +./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. @@ -1126,7 +1157,7 @@ Each `fabric.mod.json` declares client environment, Kotlin entrypoint, exact Min Run: ```bash -./gradlew :share:fabric-common:test :share:fabric-1.21.11:build :share:fabric-26.2:build +./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. @@ -1184,7 +1215,7 @@ Construct failures containing endpoint tokens, invitations, and direct candidate 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' +./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. @@ -1224,7 +1255,7 @@ share-1-21-11: java-version: "21" cache: gradle - uses: gradle/actions/setup-gradle@v4 - - run: ./gradlew :share:fabric-1.21.11:build + - run: ./gradlew :share:fabric-1-21-11:build share-26-2: runs-on: ubuntu-latest @@ -1238,7 +1269,7 @@ share-26-2: java-version: "25" cache: gradle - uses: gradle/actions/setup-gradle@v4 - - run: ./gradlew :share:fabric-26.2:build + - 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. @@ -1262,7 +1293,7 @@ Document exact checks: Run: ```bash -./gradlew :core:test :share:common:test :share:fabric-common:test :share:fabric-1.21.11:build :share:fabric-26.2:build +./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 ``` 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 index 9b1339301..8799ee3f1 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -111,12 +111,21 @@ 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 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..04dfa74cc 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,22 @@ dependencyResolutionManagement { pluginManagement { repositories { + maven("https://maven.fabricmc.net/") { + name = "Fabric" + } 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("org.jetbrains.kotlin.jvm") version "2.4.10" } includeBuild("build-logic") } @@ -85,3 +96,9 @@ include(":core") include(":bungee") include(":spigot") include(":velocity") +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-26-2") +project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") diff --git a/share/AGENTS.md b/share/AGENTS.md new file mode 100644 index 000000000..090dd4704 --- /dev/null +++ b/share/AGENTS.md @@ -0,0 +1,66 @@ +# 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. diff --git a/share/common/build.gradle.kts b/share/common/build.gradle.kts new file mode 100644 index 000000000..3527d1327 --- /dev/null +++ b/share/common/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) +} + +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/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/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/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts new file mode 100644 index 000000000..8748a5c7c --- /dev/null +++ b/share/fabric-1.21.11/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("net.fabricmc.fabric-loom-remap") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-1.21.11" +} + +java { + 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\\..*") } + } +} + +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) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts new file mode 100644 index 000000000..5a233de29 --- /dev/null +++ b/share/fabric-26.2/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("net.fabricmc.fabric-loom") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-26.2" +} + +java { + 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\\..*") } + } +} + +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) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts new file mode 100644 index 000000000..74b60c147 --- /dev/null +++ b/share/fabric-common/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) +} + +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.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() +} From f7b68cfa36e9347224e1aceb2a91b214cd446801 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:37:23 +0200 Subject: [PATCH 006/188] refactor: share endpoint token persistence --- .../connect/identity/EndpointTokenStore.java | 151 ++++++++++++++++++ .../minekube/connect/module/CommonModule.java | 64 ++------ .../identity/EndpointTokenStoreTest.java | 104 ++++++++++++ .../connect/module/CommonModuleTest.java | 8 +- .../2026-07-30-connect-share-singleplayer.md | 12 +- 5 files changed, 276 insertions(+), 63 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java create mode 100644 core/src/test/java/com/minekube/connect/identity/EndpointTokenStoreTest.java 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..0a66818c9 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -25,10 +25,6 @@ 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; @@ -46,6 +42,7 @@ 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 +52,8 @@ 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 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; @@ -137,19 +128,19 @@ public BedrockIdentityReadiness bedrockIdentityReadiness( return new BedrockIdentityReadiness(configHolder.get(), keyProvider); } + @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 +187,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/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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index f8522e3a3..c031150f4 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -295,7 +295,7 @@ git commit -m "build: add multi-version Fabric Share modules" - Consumes: `Utils.randomSecureString(20)` and Gson. - Produces: `EndpointTokenStore.load(Path, Map)`, `loadOrCreate(Path, Map)`, `save(Path,String)`, `generate()`, and `redact(String)`. -- [ ] **Step 1: Write failing token-store tests** +- [x] **Step 1: Write failing token-store tests** Cover these exact cases: @@ -316,7 +316,7 @@ assertEquals(token, new Gson().fromJson(Files.readString(file), JsonObject.class assertFalse(EndpointTokenStore.redact(token).contains(token)); ``` -- [ ] **Step 2: Run the focused test and observe failure** +- [x] **Step 2: Run the focused test and observe failure** Run: @@ -326,7 +326,7 @@ Run: Expected: compilation fails because `EndpointTokenStore` does not exist. -- [ ] **Step 3: Implement the store** +- [x] **Step 3: Implement the store** `EndpointTokenStore` must: @@ -344,7 +344,7 @@ public final class EndpointTokenStore { `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. -- [ ] **Step 4: Make CommonModule use the shared store** +- [x] **Step 4: Make CommonModule use the shared store** Replace the private `CommonModule.Token` class with an injected/provider-created `EndpointTokenStore` and: @@ -356,7 +356,7 @@ return endpointTokenStore.loadOrCreate( Keep the existing `CommonModuleTest.connectTokenIsPersistedForAllConnectClients` green. -- [ ] **Step 5: Run token and core tests** +- [x] **Step 5: Run token and core tests** Run: @@ -366,7 +366,7 @@ Run: Expected: all focused tests pass. -- [ ] **Step 6: Commit** +- [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 From 8bebb2bbef8b693605ba108f5a304be800689a3e Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:53:25 +0200 Subject: [PATCH 007/188] feat: persist and import Share endpoint identities --- .../2026-07-30-connect-share-singleplayer.md | 64 ++- .../share/identity/EndpointIdentity.kt | 52 ++ .../share/identity/EndpointIdentityStore.kt | 505 ++++++++++++++++++ .../identity/EndpointIdentityStoreTest.kt | 339 ++++++++++++ share/fabric-common/build.gradle.kts | 1 + .../share/fabric/RandomEndpointNameSource.kt | 80 +++ .../WatchEndpointCredentialValidator.kt | 131 +++++ .../fabric/RandomEndpointNameSourceTest.kt | 70 +++ .../WatchEndpointCredentialValidatorTest.kt | 186 +++++++ 9 files changed, 1406 insertions(+), 22 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/identity/EndpointIdentityStoreTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index c031150f4..2194e0ae4 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -401,15 +401,23 @@ fun interface EndpointNameSource { suspend fun create(): String } fun interface EndpointCredentialValidator { - suspend fun validate(identity: EndpointIdentity): CredentialValidation + suspend fun validate( + identity: EndpointIdentity, + ): Either } -sealed interface CredentialValidation { - data object Valid : CredentialValidation - data class Invalid(val safeMessage: String) : CredentialValidation +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 } ``` -- [ ] **Step 1: Write the identity-store tests** +- [x] **Step 1: Write the identity-store tests** Tests must prove: @@ -422,11 +430,13 @@ Tests must prove: @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`. -- [ ] **Step 2: Run and observe the missing-type failure** +- [x] **Step 2: Run and observe the missing-type failure** Run: @@ -436,7 +446,7 @@ Run: Expected: compilation fails on `EndpointIdentityStore`. -- [ ] **Step 3: Implement exact persistence semantics** +- [x] **Step 3: Implement exact persistence semantics** `EndpointIdentityStore` has this constructor and public API: @@ -452,33 +462,39 @@ class EndpointIdentityStore( endpoint: String, token: String, validator: EndpointCredentialValidator, - ): CredentialValidation + ): Either suspend fun importTokenFile( endpoint: String, tokenFile: Path, validator: EndpointCredentialValidator, - ): CredentialValidation - suspend fun resetConfirmed(): EndpointIdentity + ): Either + suspend fun resetConfirmed(): Either } ``` Use `config.json` with: ```json -{"endpoint":"amber-fox","credentialSource":"IMPORTED"} +{"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 `CredentialValidation.Valid`, then atomically replaces token first and config second while retaining backups until both moves succeed. Restore both backups when the second move fails. +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 file names. `currentOrCreate()` calls -`recoverInterruptedTransaction()` before reading identity files. When the -journal exists, restore both backups, or remove both partially created files -when no prior identity existed, then delete the journal. Delete backups and the -journal only after both final files are durable. A process crash during either -move therefore rolls back on the next load. +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. -- [ ] **Step 4: Write validator tests against MockWebServer** +- [x] **Step 4: Write validator tests against MockWebServer** Assert that a validation request sends: @@ -488,9 +504,13 @@ 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 `CredentialValidation.Invalid`; transport failure returns a safe network message. +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. -- [ ] **Step 5: Implement the Watch validator** +- [x] **Step 5: Implement the Watch validator** Expose: @@ -510,7 +530,7 @@ 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. -- [ ] **Step 6: Run focused tests** +- [x] **Step 6: Run focused tests** Run: 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/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/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts index 74b60c147..71a4c5a49 100644 --- a/share/fabric-common/build.gradle.kts +++ b/share/fabric-common/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { 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("com.squareup.okhttp3:okhttp:4.9.3") testImplementation(kotlin("test")) 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/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/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/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, + ) + } +} From 46621111fca2b3e875961d6ebd53c6ad753f5f08 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:56:19 +0200 Subject: [PATCH 008/188] feat: add Share host admission policy --- .../2026-07-30-connect-share-singleplayer.md | 10 +- .../share/admission/AdmissionController.kt | 166 ++++++++++++++ .../share/admission/AdmissionIdentity.kt | 45 ++++ .../admission/AdmissionControllerTest.kt | 202 ++++++++++++++++++ 4 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 2194e0ae4..e364d4188 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -540,7 +540,7 @@ Run: Expected: identity and validation tests pass. -- [ ] **Step 7: Commit** +- [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 @@ -581,7 +581,7 @@ enum class Ingress { CONNECT, DIRECT_LAN, DIRECT_INTERNET } enum class AdmissionAnswer { ALLOW, DENY, TIMEOUT, STOPPED, CAPACITY } ``` -- [ ] **Step 1: Write failing admission tests** +- [x] **Step 1: Write failing admission tests** Cover: @@ -597,7 +597,7 @@ Cover: Use `kotlinx.coroutines.test.runTest` and a test scheduler for the 30-second timeout. -- [ ] **Step 2: Run and observe failure** +- [x] **Step 2: Run and observe failure** Run: @@ -607,7 +607,7 @@ Run: Expected: missing admission types. -- [ ] **Step 3: Implement AdmissionController** +- [x] **Step 3: Implement AdmissionController** Expose: @@ -628,7 +628,7 @@ class AdmissionController( 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. -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** Run: 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..c4581b50b --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt @@ -0,0 +1,166 @@ +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 lock = Any() + private val requests = linkedMapOf() + private val authenticatedApprovals = mutableSetOf() + 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): AdmissionAnswer { + val lookup = synchronized(lock) { + val key = identity.admissionKey() + requests[key]?.let { + return@synchronized RequestLookup.Await(it, startTimeout = false) + } + if (connectedCount() >= maxGuests()) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) + } + if ( + identity is AdmissionIdentity.Authenticated && + identity.uuid in authenticatedApprovals + ) { + 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, + ), + ) + 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) + } + lookup.request.answer.await() + } + } + } + + 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) { + val identity = entry.value.pending.identity + if (identity is AdmissionIdentity.Authenticated) { + authenticatedApprovals += identity.uuid + } + } + publishPending() + entry.value + } + complete(completed, answer) + } + + fun resetShare() { + val stopped = synchronized(lock) { + val current = requests.values.toList() + requests.clear() + authenticatedApprovals.clear() + publishPending() + current + } + stopped.forEach { + complete(it, AdmissionAnswer.STOPPED) + } + } + + 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 publishPending() { + mutablePending.value = requests.values.map(PendingRequest::pending) + } + + private fun AdmissionIdentity.admissionKey(): AdmissionKey = when (this) { + is AdmissionIdentity.Authenticated -> AdmissionKey.Authenticated(uuid) + is AdmissionIdentity.UnverifiedOffline -> AdmissionKey.Unverified(connectionId) + } + + private sealed interface AdmissionKey { + data class Authenticated(val uuid: UUID) : AdmissionKey + data class Unverified(val connectionId: String) : AdmissionKey + } + + private class PendingRequest( + val key: AdmissionKey, + val pending: PendingAdmission, + val answer: CompletableDeferred = CompletableDeferred(), + val timeoutJob: AtomicReference = AtomicReference(), + ) + + 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..6b07dc41e --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt @@ -0,0 +1,45 @@ +package com.minekube.connect.share.admission + +import java.util.UUID + +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, +} + +data class PendingAdmission( + val requestId: UUID, + val identity: AdmissionIdentity, +) 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..0e435d768 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt @@ -0,0 +1,202 @@ +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.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class AdmissionControllerTest { + @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()) + } + + private fun kotlinx.coroutines.test.TestScope.controller( + connectedCount: () -> Int = { 0 }, + maxGuests: () -> Int = { 8 }, + ) = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = connectedCount, + maxGuests = maxGuests, + ) + + 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") + } +} From fba5629ca120e7be5c09461ad75f49c5b2ca7e2c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:02:27 +0200 Subject: [PATCH 009/188] feat: gate Connect sessions before tunneling --- .../minekube/connect/module/CommonModule.java | 6 + .../connect/register/WatcherRegister.java | 135 +++++++++++++- .../watch/AllowAllSessionAdmissionGate.java | 16 ++ .../watch/SessionAdmissionDecision.java | 55 ++++++ .../connect/watch/SessionAdmissionGate.java | 11 ++ .../connect/register/WatcherRegisterTest.java | 164 +++++++++++++++++- .../AllowAllSessionAdmissionGateTest.java | 19 ++ .../2026-07-30-connect-share-singleplayer.md | 10 +- 8 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java create mode 100644 core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java create mode 100644 core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java create mode 100644 core/src/test/java/com/minekube/connect/watch/AllowAllSessionAdmissionGateTest.java 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 0a66818c9..a619c5b97 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -29,6 +29,7 @@ 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; @@ -52,6 +53,8 @@ import com.minekube.connect.util.HttpUtils; import com.minekube.connect.util.LanguageManager; import com.minekube.connect.util.Metrics; +import com.minekube.connect.watch.AllowAllSessionAdmissionGate; +import com.minekube.connect.watch.SessionAdmissionGate; import java.io.IOException; import java.nio.file.Path; import java.util.concurrent.TimeUnit; @@ -75,6 +78,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 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..9c98ea732 100644 --- a/core/src/main/java/com/minekube/connect/register/WatcherRegister.java +++ b/core/src/main/java/com/minekube/connect/register/WatcherRegister.java @@ -40,6 +40,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 +51,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 +74,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 +244,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 +301,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 +336,7 @@ public void onError(Throwable t) { : " (cause: " + t.getCause().toString() + ")" ) ); + cancelPendingAdmissions(); cancelResetBackOffTimer(); retry(); } @@ -325,6 +346,7 @@ public void onCompleted() { if (!acceptTerminalEvent()) { return; } + cancelPendingAdmissions(); cancelResetBackOffTimer(); retry(); } @@ -357,6 +379,101 @@ 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()) { + reject(proposal, Status.newBuilder() + .setCode(Code.PERMISSION_DENIED_VALUE) + .setMessage(decision.getSafeMessage()) + .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/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/test/java/com/minekube/connect/register/WatcherRegisterTest.java b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java index 463000b4e..bd3ff9acb 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,14 @@ 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.minekube.connect.api.SimpleConnectApi; import com.minekube.connect.api.inject.PlatformInjector; import com.minekube.connect.api.logger.ConnectLogger; @@ -27,6 +33,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 +46,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 +411,130 @@ 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()); + }); + 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 +579,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 +606,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 +617,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/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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index e364d4188..0748ac962 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -638,7 +638,7 @@ Run: Expected: all seven cases pass. -- [ ] **Step 5: Commit** +- [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 @@ -675,7 +675,7 @@ public final class SessionAdmissionDecision { } ``` -- [ ] **Step 1: Add failing WatcherRegister tests** +- [x] **Step 1: Add failing WatcherRegister tests** Add tests that hold a `CompletableFuture` and assert: @@ -686,7 +686,7 @@ 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. -- [ ] **Step 2: Run and observe failure** +- [x] **Step 2: Run and observe failure** Run: @@ -696,7 +696,7 @@ Run: Expected: compilation fails because the gate does not exist. -- [ ] **Step 3: Implement the default gate and WatcherRegister sequencing** +- [x] **Step 3: Implement the default gate and WatcherRegister sequencing** Use Guice `OptionalBinder` in `CommonModule`: set `AllowAllSessionAdmissionGate` as the default `SessionAdmissionGate`, and let @@ -713,7 +713,7 @@ started.get() 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. -- [ ] **Step 4: Run Core tests** +- [x] **Step 4: Run Core tests** Run: From a3d5f5d797ad19be6292824cc30ef4752fa067b6 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:10:03 +0200 Subject: [PATCH 010/188] feat: add Connect Share lifecycle --- .../2026-07-30-connect-share-singleplayer.md | 24 +- .../connect/share/ConnectShareIngress.kt | 17 ++ .../connect/share/MinecraftShareBridge.kt | 12 + .../connect/share/ShareCoordinator.kt | 167 +++++++++++ .../minekube/connect/share/ShareOptions.kt | 25 ++ .../com/minekube/connect/share/ShareState.kt | 33 +++ .../connect/share/ShareCoordinatorTest.kt | 268 ++++++++++++++++++ 7 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 0748ac962..d1c7ac708 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -723,7 +723,7 @@ Run: Expected: focused tests pass and existing plugin behavior remains immediate-allow. -- [ ] **Step 5: Commit** +- [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 @@ -770,7 +770,7 @@ data class ConnectShareHandle( ) ``` -- [ ] **Step 1: Write state and cleanup tests** +- [x] **Step 1: Write state and cleanup tests** Prove: @@ -781,9 +781,10 @@ Prove: @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`() ``` -- [ ] **Step 2: Run and observe missing production types** +- [x] **Step 2: Run and observe missing production types** Run: @@ -793,7 +794,7 @@ Run: Expected: compilation failure. -- [ ] **Step 3: Implement the coordinator** +- [x] **Step 3: Implement the coordinator** `ShareState` is: @@ -807,9 +808,20 @@ sealed interface ShareState { } ``` -`ShareCoordinator.start` runs under a mutex, creates the bridge, loads identity, starts Connect, and publishes `Sharing`. `stop` snapshots handles under the mutex, publishes `Stopping`, closes ingress, closes bridge, resets admission, then publishes `Idle`. Every close runs even when a previous close throws; aggregate failures into logs but keep UI messages sanitized. +`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. -- [ ] **Step 4: Run tests and commit** +- [x] **Step 4: Run tests and commit** Run: 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/MinecraftShareBridge.kt b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt new file mode 100644 index 000000000..725063aee --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt @@ -0,0 +1,12 @@ +package com.minekube.connect.share + +import java.net.SocketAddress + +data class LocalShareTarget( + val address: SocketAddress, + 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/ShareCoordinator.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt new file mode 100644 index 000000000..6de783adb --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -0,0 +1,167 @@ +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 failureReporter: (String) -> Unit = {}, +) { + private val lifecycleMutex = Mutex() + private val mutableState = MutableStateFlow(ShareState.Idle) + 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() }, + ) + val identity = identityProvider() + val connect = install( + acquire = { ingress.start(identity, target.address) }, + release = { acquired, _ -> acquired.close() }, + ) + AcquiredShare(target, connect) + } + val (acquired, release) = managedShare.allocateSafely() + val sharing = ShareState.Sharing( + endpoint = acquired.connect.endpoint, + address = acquired.connect.publicAddress, + ) + active = ActiveShare(release) + 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() + + private data class AcquiredShare( + val target: LocalShareTarget, + val connect: ConnectShareHandle, + ) + + private data class ActiveShare( + val release: suspend (ExitCase) -> Unit, + ) + + @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" + } +} 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..b898bc470 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt @@ -0,0 +1,25 @@ +package com.minekube.connect.share + +data class ShareOptions( + val gameMode: ShareGameMode, + val allowCheats: Boolean, + val maxGuests: Int = 8, +) { + 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..0a623d2e8 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt @@ -0,0 +1,33 @@ +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, + ) : ShareState + + 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/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..afe3acae6 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -0,0 +1,268 @@ +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 `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 `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 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, + 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" + }, + 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 = InetSocketAddress.createUnresolved("127.0.0.1", 25565), + close = { + events += "bridge-close" + }, + ) + } + val ingress = ConnectShareIngress { identity, target -> + val handle = ingressStart(identity, target) + handle.copy(close = ingressClose) + } + return Fixture( + coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityProvider, + admission = admission, + 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 IDENTITY = EndpointIdentity( + endpoint = "amber-fox", + token = "T-AAAAAAAAAAAAAAAAAAAA", + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + } +} From 96a725e99f1bb46793211f239bbf5f56677bce8d Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:20:31 +0200 Subject: [PATCH 011/188] feat: add embedded Fabric Connect ingress --- .../com/minekube/connect/ConnectPlatform.java | 70 ++++-- .../connect/config/ConnectConfig.java | 29 ++- .../connect/EmbeddedConnectPlatformTest.java | 203 ++++++++++++++++++ .../2026-07-30-connect-share-singleplayer.md | 16 +- share/fabric-common/build.gradle.kts | 1 + .../share/fabric/FabricConnectIngress.kt | 198 +++++++++++++++++ .../share/fabric/FabricPlatformUtils.kt | 16 ++ .../fabric/FabricSessionAdmissionGate.kt | 152 +++++++++++++ .../share/fabric/FabricConnectIngressTest.kt | 107 +++++++++ .../fabric/FabricSessionAdmissionGateTest.kt | 178 +++++++++++++++ 10 files changed, 945 insertions(+), 25 deletions(-) create mode 100644 core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricPlatformUtils.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricConnectIngressTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt 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/config/ConnectConfig.java b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java index 85d7aaa0e..e1d0ee772 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,9 @@ import com.minekube.connect.util.Utils; import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; import lombok.Getter; /** @@ -36,6 +39,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 +53,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. @@ -68,6 +74,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; 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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index d1c7ac708..8997a50c2 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -855,7 +855,7 @@ git commit -m "feat: add Connect Share lifecycle" - 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`. -- [ ] **Step 1: Write failing embedded-platform tests** +- [x] **Step 1: Write failing embedded-platform tests** Assert: @@ -867,7 +867,7 @@ 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. -- [ ] **Step 2: Add the embedded Core entry point** +- [x] **Step 2: Add the embedded Core entry point** Add: @@ -887,7 +887,7 @@ public void initEmbedded( Share the common initialization tail with the existing `init`; do not change plugin config loading. -- [ ] **Step 3: Implement the Kotlin admission adapter** +- [x] **Step 3: Implement the Kotlin admission adapter** `FabricSessionAdmissionGate.request` maps: @@ -914,7 +914,7 @@ offline profile to Ingress.CONNECT)`. It completes before vanilla moves the connection into configuration/play state. -- [ ] **Step 4: Implement FabricConnectIngress** +- [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: @@ -926,7 +926,9 @@ allowOfflineModePlayers = true ``` Use the already persisted `token.json`; do not generate or write credentials -inside `start`. Return the `ConnectShareHandle` defined in Task 6: +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( @@ -938,7 +940,7 @@ ConnectShareHandle( where `publicAddress` is `.play.minekube.net`. -- [ ] **Step 5: Run focused and Core regression tests** +- [x] **Step 5: Run focused and Core regression tests** Run: @@ -948,7 +950,7 @@ Run: Expected: embedded lifecycle and admission mapping pass. -- [ ] **Step 6: Commit** +- [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 diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts index 71a4c5a49..000e4d29b 100644 --- a/share/fabric-common/build.gradle.kts +++ b/share/fabric-common/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { 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")) 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..b9ec3d0e1 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt @@ -0,0 +1,198 @@ +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.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 scope: CoroutineScope, + private val runtimeFactory: FabricConnectRuntimeFactory, +) : ConnectShareIngress { + constructor( + dataDirectory: Path, + platformInjector: CommonPlatformInjector, + logger: ConnectLogger, + platformUtils: FabricPlatformUtils, + admission: AdmissionController, + scope: CoroutineScope, + ) : this( + dataDirectory = dataDirectory, + admission = admission, + scope = scope, + 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) + 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, + ) = FabricConnectIngress( + dataDirectory = dataDirectory, + admission = admission, + scope = scope, + 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 { + platform.disable() + } + } catch (failure: Throwable) { + try { + platform.disable() + } catch (cleanupFailure: Throwable) { + failure.addSuppressed(cleanupFailure) + } + 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/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..6014919b3 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt @@ -0,0 +1,152 @@ +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.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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class FabricSessionAdmissionGate( + private val admission: AdmissionController, + private val scope: CoroutineScope, +) : SessionAdmissionGate { + private val stopped = AtomicBoolean() + private val active = ConcurrentHashMap, Job>() + + override fun request( + proposal: SessionProposal, + ): CompletionStage { + 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 { + future.complete(admission.request(identity).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 + } + + 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 -> SessionAdmissionDecision.deny("Host denied this connection") + AdmissionAnswer.TIMEOUT -> SessionAdmissionDecision.deny("Host approval timed out") + AdmissionAnswer.STOPPED -> SessionAdmissionDecision.deny("Sharing stopped") + AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny("Share is full") + } + + 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" + } +} + +class FabricLocalLoginAdmission( + private val admission: AdmissionController, +) { + suspend fun request( + name: String, + uuid: UUID, + connectionId: String, + minecraftAuthenticated: Boolean, + ): AdmissionAnswer { + val identity = if (minecraftAuthenticated) { + AdmissionIdentity.Authenticated( + name = name, + uuid = uuid, + source = AuthSource.MOJANG, + ) + } else { + AdmissionIdentity.UnverifiedOffline( + name = name, + uuid = uuid, + connectionId = connectionId, + ingress = Ingress.CONNECT, + ) + } + return admission.request(identity) + } +} 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/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt new file mode 100644 index 000000000..885a75a03 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt @@ -0,0 +1,178 @@ +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.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 `Connect authenticated profile waits for host approval`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + + 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) + } + + @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("Host denied this connection", decision.safeMessage) + } + + @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 local = FabricLocalLoginAdmission(admission) + val authenticated = async { + local.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-authenticated", + minecraftAuthenticated = true, + ) + } + runCurrent() + val authenticatedIdentity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals(AuthSource.MOJANG, authenticatedIdentity.source) + admission.answer(admission.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, authenticated.await()) + + val offline = async { + local.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-offline", + minecraftAuthenticated = false, + ) + } + runCurrent() + val offlineIdentity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals("connection-offline", offlineIdentity.connectionId) + assertEquals(Ingress.CONNECT, offlineIdentity.ingress) + 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") + } +} From 1650a2d2f2f42a752ed2831a84441113ca480d00 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:37:18 +0200 Subject: [PATCH 012/188] feat: bridge Connect into 1.21.11 singleplayer --- .../2026-07-30-connect-share-singleplayer.md | 12 +- share/fabric-1.21.11/build.gradle.kts | 7 + .../v1_21_11/mixin/ConnectionAccessor.java | 12 ++ .../mixin/IntegratedServerAccessor.java | 19 ++ .../v1_21_11/mixin/IntegratedServerMixin.java | 24 +++ .../mixin/LanServerPingerAccessor.java | 12 ++ .../ServerConnectionListenerAccessor.java | 13 ++ .../mixin/ServerConnectionListenerMixin.java | 57 ++++++ .../mixin/ServerLoginPacketListenerMixin.java | 117 +++++++++++ .../v1_21_11/CapturedServerTransport.kt | 108 ++++++++++ .../v1_21_11/ConnectGameProfileMapper.kt | 52 +++++ .../fabric/v1_21_11/Minecraft12111Bridge.kt | 187 +++++++++++++++++ .../v1_21_11/Minecraft12111LoginAdmission.kt | 42 ++++ .../VanillaMinecraft12111Transport.kt | 192 ++++++++++++++++++ .../connect-share-fabric-1.21.11.mixins.json | 20 ++ .../src/main/resources/fabric.mod.json | 17 ++ .../v1_21_11/CapturedServerTransportTest.kt | 57 ++++++ .../v1_21_11/ConnectGameProfileMapperTest.kt | 47 +++++ .../v1_21_11/Minecraft12111BridgeTest.kt | 125 ++++++++++++ .../fabric/FabricSessionAdmissionGate.kt | 73 +++++++ .../FabricLocalLoginAdmissionGateTest.kt | 82 ++++++++ 21 files changed, 1269 insertions(+), 6 deletions(-) create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt create mode 100644 share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json create mode 100644 share/fabric-1.21.11/src/main/resources/fabric.mod.json create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 8997a50c2..760f7e20a 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -973,7 +973,7 @@ git commit -m "feat: add embedded Fabric Connect ingress" - Consumes: `IntegratedServer.publishServer`, `ServerConnectionListener.startTcpServerListener`, `LocalServerChannelWrapper`, and Connect channel attributes. - Produces: `Minecraft12111Bridge : MinecraftShareBridge`. -- [ ] **Step 1: Generate and inspect exact 1.21.11 sources** +- [x] **Step 1: Generate and inspect exact 1.21.11 sources** Run: @@ -993,7 +993,7 @@ 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. -- [ ] **Step 2: Write the bridge test before mixins** +- [x] **Step 2: Write the bridge test before mixins** Use a fake captured transport and assert: @@ -1006,7 +1006,7 @@ assertEquals(0, capturedListenerCountAfterClose) Opening twice after close must succeed; opening while active must fail without adding a second listener. -- [ ] **Step 3: Capture vanilla's child initializer and force loopback** +- [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: @@ -1022,7 +1022,7 @@ It must leave ordinary vanilla publishing unchanged unless `CapturedServerTransp Minecraft's `Connection` so the login mixin can read Connect's channel attribute without reflection. -- [ ] **Step 4: Bind the local channel and implement stop** +- [x] **Step 4: Bind the local channel and implement stop** After `publishServer`, identify exactly one newly added loopback `ChannelFuture`. Bind: @@ -1038,13 +1038,13 @@ ServerBootstrap() 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. -- [ ] **Step 5: Inject Connect-authenticated login profiles** +- [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. -- [ ] **Step 6: Run adapter tests and a headless launch smoke** +- [x] **Step 6: Run adapter tests and a headless launch smoke** Run: diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 8748a5c7c..7d7c82a1f 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -49,3 +49,10 @@ dependencies { tasks.test { useJUnitPlatform() } + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} 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..c274069e2 --- /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.fabric.v1_21_11.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/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..60806e753 --- /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.fabric.v1_21_11.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..569a4199f --- /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,117 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.mojang.authlib.GameProfile; +import com.minekube.connect.api.ConnectAttributes; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.network.netty.LocalSession; +import com.minekube.connect.share.admission.AdmissionAnswer; +import com.minekube.connect.share.fabric.v1_21_11.ConnectGameProfileMapper; +import com.minekube.connect.share.fabric.v1_21_11.Minecraft12111LoginAdmission; +import io.netty.channel.Channel; +import java.util.concurrent.CompletableFuture; +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) { + Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); + ConnectPlayer player = channel.attr(ConnectAttributes.CONNECT_PLAYER).get(); + if (player == null) { + return; + } + + GameProfile profile = + ConnectGameProfileMapper.toMinecraftOrNull(player.getGameProfile()); + if (profile == null || !hello.name().equalsIgnoreCase(profile.name())) { + 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) { + Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); + LocalSession.Context context = LocalSession.context(channel).orElse(null); + if (context == null || !context.getPlayer().getAuth().isPassthrough()) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + CompletableFuture decision = Minecraft12111LoginAdmission.request( + profile.name(), + profile.id(), + context.getPlayer().getSessionId(), + server.usesAuthentication() && !connection.isMemoryConnection()) + .toCompletableFuture(); + channel.closeFuture().addListener(ignored -> decision.cancel(false)); + decision.whenComplete((answer, failure) -> server.execute(() -> { + if (!connection.isConnected()) { + return; + } + if (failure != null || answer != AdmissionAnswer.ALLOW) { + disconnect(connectShare$denialReason(answer)); + return; + } + connectShare$admissionAllowed = true; + })); + } + + @Unique + private Component connectShare$denialReason(@Nullable AdmissionAnswer answer) { + if (answer == AdmissionAnswer.TIMEOUT) { + return Component.literal("Host approval timed out"); + } + if (answer == AdmissionAnswer.CAPACITY) { + return Component.literal("This share is full"); + } + if (answer == AdmissionAnswer.STOPPED) { + return Component.literal("Sharing stopped"); + } + return Component.literal("Host denied this connection"); + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt new file mode 100644 index 000000000..b879f5296 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt @@ -0,0 +1,108 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +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 { + synchronized(captureLock) { + armed + ?.takeIf { it.owner === Thread.currentThread() } + ?.childInitializer = initializer + } + return initializer + } + + @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/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..f67e08a4b --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt @@ -0,0 +1,52 @@ +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.google.common.collect.ArrayListMultimap +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.mojang.authlib.properties.PropertyMap +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 = ArrayListMultimap.create() + source.properties.forEach { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + val mapped = if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + properties.put(property.name, mapped) + } + GameProfile( + source.uniqueId, + source.username, + PropertyMap(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/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..f7399f833 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt @@ -0,0 +1,187 @@ +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.minekube.connect.inject.CommonPlatformInjector +import com.minekube.connect.share.LocalShareTarget +import com.minekube.connect.share.MinecraftShareBridge +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.local.LocalAddress +import java.net.InetSocketAddress +import java.net.SocketAddress + +class Minecraft12111Bridge internal constructor( + private val transport: Minecraft12111Transport, + private val localBinder: LocalShareChannelBinder, + private val loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : CommonPlatformInjector(), MinecraftShareBridge { + constructor() : this( + VanillaMinecraft12111Transport(), + NettyLocalShareChannelBinder(), + ) + + constructor( + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, + ) : this( + VanillaMinecraft12111Transport(), + NettyLocalShareChannelBinder(), + loginAdmissionFactory, + ) + + 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 admission: AutoCloseable? = null + try { + validatePublished(published).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + local = localBinder.bind(published.childInitializer) + validateLocal(local).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + published.addLocalListener(local) + localAdded = true + admission = loginAdmissionFactory + ?.invoke() + ?.let(Minecraft12111LoginAdmission::install) + val acquired = ActiveTransport(published, local, admission) + active = acquired + serverSocketAddress = local.address + LocalShareTarget(local.address) { + close(acquired) + } + } catch (failure: Throwable) { + admission?.close() + if (localAdded) { + published.removeLocalListener(checkNotNull(local)) + } + local?.close() + published.close() + throw failure + } + } + + override fun inject(): Boolean = synchronized(lifecycleLock) { + active != null + } + + override fun isInjected(): Boolean = synchronized(lifecycleLock) { + active != null + } + + override fun shutdown() { + synchronized(lifecycleLock) { + active?.stopAdmission() + active?.closeLocal() + } + } + + 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 class ActiveTransport( + private val published: PublishedMinecraftTransport, + private val local: LocalShareChannel, + private val admission: AutoCloseable?, + ) { + private var admissionStopped = false + private var localClosed = false + + fun stopAdmission() { + if (admissionStopped) { + return + } + admissionStopped = true + admission?.close() + } + + fun closeLocal() { + if (localClosed) { + return + } + localClosed = true + published.removeLocalListener(local) + local.close() + } + + fun close() { + stopAdmission() + closeLocal() + published.close() + } + } +} + +internal fun interface Minecraft12111Transport { + fun publish(options: ShareOptions): PublishedMinecraftTransport +} + +internal interface PublishedMinecraftTransport { + val address: InetSocketAddress + val childInitializer: ChannelInitializer + + fun addLocalListener(listener: LocalShareChannel) + + fun removeLocalListener(listener: LocalShareChannel) + + fun close() +} + +internal fun interface LocalShareChannelBinder { + fun bind(childInitializer: ChannelInitializer): LocalShareChannel +} + +internal interface LocalShareChannel { + val address: SocketAddress + + fun close() +} + +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/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt new file mode 100644 index 000000000..8a7ee5988 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt @@ -0,0 +1,42 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.atomic.AtomicReference + +object Minecraft12111LoginAdmission { + 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, + ): CompletionStage { + val gate = installed.get() + if (gate == null) { + return CompletableFuture.completedFuture(AdmissionAnswer.STOPPED) + } + return gate.request( + name = name, + uuid = uuid, + connectionId = connectionId, + minecraftAuthenticated = minecraftAuthenticated, + ) + } +} 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..5044a4bfb --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt @@ -0,0 +1,192 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.network.netty.LocalServerChannelWrapper +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.bootstrap.ServerBootstrap +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import io.netty.util.concurrent.DefaultThreadFactory +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, + ) + } 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, +) : PublishedMinecraftTransport { + override fun addLocalListener(listener: LocalShareChannel) { + val future = (listener as NettyLocalShareChannel).future + synchronized(channels) { + check(channels.add(future)) { + "Minecraft already tracks the Connect Share local listener" + } + } + } + + override fun removeLocalListener(listener: LocalShareChannel) { + val future = (listener as NettyLocalShareChannel).future + synchronized(channels) { + channels.remove(future) + } + } + + override fun close() { + synchronized(channels) { + channels.remove(loopback) + } + loopback.closeChannel() + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } +} + +internal class NettyLocalShareChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + ): LocalShareChannel { + val eventLoop = DefaultEventLoopGroup( + 0, + DefaultThreadFactory( + "Connect Share local", + Thread.MAX_PRIORITY, + ), + ) + try { + val future = ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(childInitializer) + .group(eventLoop) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() + return NettyLocalShareChannel(future, eventLoop) + } catch (failure: Throwable) { + eventLoop.shutdownGracefully().syncUninterruptibly() + throw failure + } + } +} + +private class NettyLocalShareChannel( + val future: ChannelFuture, + private val eventLoop: EventLoopGroup, +) : LocalShareChannel { + override val address = future.channel().localAddress() + + override fun close() { + future.closeChannel() + eventLoop.shutdownGracefully().syncUninterruptibly() + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} 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..12dcd00a3 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json @@ -0,0 +1,20 @@ +{ + "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" + ], + "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..703d736bb --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "connect_share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "*", + "mixins": [ + "connect-share-fabric-1.21.11.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "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..e12733288 --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt @@ -0,0 +1,57 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +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.assertSame +import kotlin.test.assertTrue + +class CapturedServerTransportTest { + @Test + fun `captures the exact vanilla initializer and group only for the armed thread`() { + val initializer = NoopInitializer + val group = DefaultEventLoopGroup(1) + try { + val lease = CapturedServerTransport.arm() + + assertTrue(CapturedServerTransport.isShareStartArmed()) + assertSame( + initializer, + CapturedServerTransport.captureChildInitializer(initializer), + ) + assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) + + var otherThreadArmed = true + thread { + otherThreadArmed = CapturedServerTransport.isShareStartArmed() + }.join() + + val captured = lease.complete().getOrNull() + requireNotNull(captured) + assertSame(initializer, 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/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..4d51c7893 --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt @@ -0,0 +1,125 @@ +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.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 + + 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 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, + ): 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-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 index 6014919b3..68f9a6b4f 100644 --- 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 @@ -150,3 +150,76 @@ class FabricLocalLoginAdmission( return admission.request(identity) } } + +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, + ): 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, + ), + ) + } 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/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..4bb5ce895 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt @@ -0,0 +1,82 @@ +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 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 `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, + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + val identity = assertIs(pending.identity) + assertEquals("connection-1", identity.connectionId) + 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") + } +} From c6adf4f556606dcd33822a28e8497f5f8a66b093 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:49:51 +0200 Subject: [PATCH 013/188] feat: bridge Connect into 26.2 singleplayer --- .../2026-07-30-connect-share-singleplayer.md | 10 +- .../connect/share}/CapturedServerTransport.kt | 2 +- .../connect/share/VersionedMinecraftBridge.kt | 261 ++++++++++++++++++ .../connect/share/AdapterContractTest.kt | 136 +++++++++ .../v1_21_11/mixin/IntegratedServerMixin.java | 2 +- .../mixin/ServerConnectionListenerMixin.java | 2 +- .../mixin/ServerLoginPacketListenerMixin.java | 60 +--- .../fabric/v1_21_11/Minecraft12111Bridge.kt | 197 ++----------- .../v1_21_11/Minecraft12111LoginBridge.kt | 90 ++++++ .../VanillaMinecraft12111Transport.kt | 51 +--- .../v1_21_11/CapturedServerTransportTest.kt | 1 + share/fabric-26.2/build.gradle.kts | 7 + .../v26_2/mixin/ConnectionAccessor.java | 12 + .../v26_2/mixin/IntegratedServerAccessor.java | 16 ++ .../v26_2/mixin/IntegratedServerMixin.java | 24 ++ .../v26_2/mixin/LanServerPingerAccessor.java | 12 + .../ServerConnectionListenerAccessor.java | 13 + .../mixin/ServerConnectionListenerMixin.java | 57 ++++ .../mixin/ServerLoginPacketListenerMixin.java | 83 ++++++ .../fabric/v26_2/ConnectGameProfileMapper.kt | 52 ++++ .../share/fabric/v26_2/Minecraft262Bridge.kt | 42 +++ .../fabric/v26_2/Minecraft262LoginBridge.kt | 90 ++++++ .../v26_2/VanillaMinecraft262Transport.kt | 155 +++++++++++ .../connect-share-fabric-26.2.mixins.json | 20 ++ .../src/main/resources/fabric.mod.json | 17 ++ .../v26_2/ConnectGameProfileMapperTest.kt | 34 +++ .../fabric/v26_2/Minecraft262BridgeTest.kt | 95 +++++++ .../fabric/FabricLoginAdmissionRegistry.kt} | 5 +- 28 files changed, 1272 insertions(+), 274 deletions(-) rename share/{fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11 => common/src/main/kotlin/com/minekube/connect/share}/CapturedServerTransport.kt (98%) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ConnectionAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt create mode 100644 share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json create mode 100644 share/fabric-26.2/src/main/resources/fabric.mod.json create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt rename share/{fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt => fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt} (88%) diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 760f7e20a..43acd590e 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -1072,7 +1072,7 @@ git commit -m "feat: bridge Connect into 1.21.11 singleplayer" - Consumes: the same `MinecraftShareBridge` contract and unobfuscated 26.2 Minecraft classes. - Produces: `Minecraft262Bridge : MinecraftShareBridge` with behavior identical to Task 8. -- [ ] **Step 1: Generate 26.2 sources and verify names** +- [x] **Step 1: Generate 26.2 sources and verify names** Run: @@ -1082,7 +1082,7 @@ Run: 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`. -- [ ] **Step 2: Write parity tests** +- [x] **Step 2: Write parity tests** Run the same contract fixture against both fake adapters: @@ -1096,11 +1096,11 @@ fun bridgeContract(factory: () -> MinecraftShareBridgeHarness) { } ``` -- [ ] **Step 3: Implement the 26.2 bridge and mixins** +- [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. -- [ ] **Step 4: Build and smoke both versions** +- [x] **Step 4: Build and smoke both versions** Run: @@ -1110,7 +1110,7 @@ Run: Expected: both artifacts compile and parity tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add share/fabric-26.2 share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt similarity index 98% rename from share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt rename to share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt index b879f5296..cbecf0d0d 100644 --- a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -1,4 +1,4 @@ -package com.minekube.connect.share.fabric.v1_21_11 +package com.minekube.connect.share import arrow.core.Either import arrow.core.left 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..39733eaf6 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -0,0 +1,261 @@ +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.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import io.netty.util.concurrent.DefaultThreadFactory +import java.net.InetSocketAddress +import java.net.SocketAddress + +open class VersionedMinecraftBridge( + private val transport: MinecraftVersionTransport, + private val localBinder: LocalShareChannelBinder, + private val loginAdmissionAcquire: (() -> AutoCloseable)? = null, +) : CommonPlatformInjector(), MinecraftShareBridge { + 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 admission: AutoCloseable? = null + try { + validatePublished(published).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + local = localBinder.bind(published.childInitializer) + validateLocal(local).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + published.addLocalListener(local) + localAdded = true + admission = loginAdmissionAcquire?.invoke() + val acquired = ActiveTransport(published, local, admission) + active = acquired + serverSocketAddress = local.address + LocalShareTarget(local.address) { + close(acquired) + } + } catch (failure: Throwable) { + var cleanup: Throwable? = failure + cleanup = releaseAfter(cleanup) { + admission?.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 class ActiveTransport( + private val published: PublishedMinecraftTransport, + private val local: LocalShareChannel, + private val admission: AutoCloseable?, + ) { + private var admissionStopped = false + private var localClosed = 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 (localClosed) { + return primary + } + localClosed = true + var failure = releaseAfter(primary) { + 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 + + fun addLocalListener(listener: LocalShareChannel) + + fun removeLocalListener(listener: LocalShareChannel) + + fun close() +} + +fun interface LocalShareChannelBinder { + fun bind(childInitializer: ChannelInitializer): LocalShareChannel +} + +interface LocalShareChannel { + val address: SocketAddress + val future: ChannelFuture? + get() = null + + fun close() +} + +class NettyLocalShareChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + ): LocalShareChannel { + val eventLoop = DefaultEventLoopGroup( + 0, + DefaultThreadFactory( + "Connect Share local", + Thread.MAX_PRIORITY, + ), + ) + try { + val future = ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(childInitializer) + .group(eventLoop) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() + return NettyLocalShareChannel(future, eventLoop) + } catch (failure: Throwable) { + eventLoop.shutdownGracefully().syncUninterruptibly() + throw failure + } + } +} + +private class NettyLocalShareChannel( + override val future: ChannelFuture, + private val eventLoop: EventLoopGroup, +) : LocalShareChannel { + override val address = future.channel().localAddress() + + override fun close() { + future.closeChannel() + eventLoop.shutdownGracefully().syncUninterruptibly() + } +} + +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/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..19031b87c --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt @@ -0,0 +1,136 @@ +package com.minekube.connect.share + +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +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 bridge = VersionedMinecraftBridge(harness, FakeLocalBinder()) + + 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) + } + + @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 + 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 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, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("failing-local") + + override fun close() { + closed = true + throw IllegalStateException("local close failed") + } + } + } + + private class FakeLocalBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + ): LocalShareChannel = 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/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 index c274069e2..f001cd246 100644 --- 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 @@ -1,6 +1,6 @@ package com.minekube.connect.share.fabric.v1_21_11.mixin; -import com.minekube.connect.share.fabric.v1_21_11.CapturedServerTransport; +import com.minekube.connect.share.CapturedServerTransport; import net.minecraft.client.server.IntegratedServer; import net.minecraft.client.server.LanServerPinger; import org.spongepowered.asm.mixin.Mixin; 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 index 60806e753..8f2912143 100644 --- 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 @@ -1,6 +1,6 @@ package com.minekube.connect.share.fabric.v1_21_11.mixin; -import com.minekube.connect.share.fabric.v1_21_11.CapturedServerTransport; +import com.minekube.connect.share.CapturedServerTransport; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelInitializer; 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 index 569a4199f..15b6d5232 100644 --- 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 @@ -1,14 +1,7 @@ package com.minekube.connect.share.fabric.v1_21_11.mixin; import com.mojang.authlib.GameProfile; -import com.minekube.connect.api.ConnectAttributes; -import com.minekube.connect.api.player.ConnectPlayer; -import com.minekube.connect.network.netty.LocalSession; -import com.minekube.connect.share.admission.AdmissionAnswer; -import com.minekube.connect.share.fabric.v1_21_11.ConnectGameProfileMapper; -import com.minekube.connect.share.fabric.v1_21_11.Minecraft12111LoginAdmission; -import io.netty.channel.Channel; -import java.util.concurrent.CompletableFuture; +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; @@ -42,15 +35,13 @@ public abstract class ServerLoginPacketListenerMixin { private void connectShare$acceptConnectProfile( ServerboundHelloPacket hello, CallbackInfo callback) { - Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); - ConnectPlayer player = channel.attr(ConnectAttributes.CONNECT_PLAYER).get(); - if (player == null) { + if (!Minecraft12111LoginBridge.hasConnectIdentity(connection)) { return; } - GameProfile profile = - ConnectGameProfileMapper.toMinecraftOrNull(player.getGameProfile()); - if (profile == null || !hello.name().equalsIgnoreCase(profile.name())) { + GameProfile profile = Minecraft12111LoginBridge.authenticatedProfile( + connection, hello.name()); + if (profile == null) { disconnect(Component.literal("Connect identity is invalid")); callback.cancel(); return; @@ -68,9 +59,7 @@ public abstract class ServerLoginPacketListenerMixin { private void connectShare$awaitPassthroughAdmission( GameProfile profile, CallbackInfo callback) { - Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); - LocalSession.Context context = LocalSession.context(channel).orElse(null); - if (context == null || !context.getPlayer().getAuth().isPassthrough()) { + if (!Minecraft12111LoginBridge.isPassthroughConnect(connection)) { return; } if (connectShare$admissionAllowed) { @@ -82,36 +71,11 @@ public abstract class ServerLoginPacketListenerMixin { return; } connectShare$admissionStarted = true; - CompletableFuture decision = Minecraft12111LoginAdmission.request( - profile.name(), - profile.id(), - context.getPlayer().getSessionId(), - server.usesAuthentication() && !connection.isMemoryConnection()) - .toCompletableFuture(); - channel.closeFuture().addListener(ignored -> decision.cancel(false)); - decision.whenComplete((answer, failure) -> server.execute(() -> { - if (!connection.isConnected()) { - return; - } - if (failure != null || answer != AdmissionAnswer.ALLOW) { - disconnect(connectShare$denialReason(answer)); - return; - } - connectShare$admissionAllowed = true; - })); - } - - @Unique - private Component connectShare$denialReason(@Nullable AdmissionAnswer answer) { - if (answer == AdmissionAnswer.TIMEOUT) { - return Component.literal("Host approval timed out"); - } - if (answer == AdmissionAnswer.CAPACITY) { - return Component.literal("This share is full"); - } - if (answer == AdmissionAnswer.STOPPED) { - return Component.literal("Sharing stopped"); - } - return Component.literal("Host denied this connection"); + Minecraft12111LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); } } 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 index f7399f833..6b4e7cc23 100644 --- 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 @@ -1,24 +1,30 @@ 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.minekube.connect.inject.CommonPlatformInjector -import com.minekube.connect.share.LocalShareTarget -import com.minekube.connect.share.MinecraftShareBridge -import com.minekube.connect.share.ShareOptions +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.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 -import io.netty.channel.Channel -import io.netty.channel.ChannelInitializer -import io.netty.channel.local.LocalAddress -import java.net.InetSocketAddress -import java.net.SocketAddress class Minecraft12111Bridge internal constructor( - private val transport: Minecraft12111Transport, - private val localBinder: LocalShareChannelBinder, - private val loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, -) : CommonPlatformInjector(), MinecraftShareBridge { + transport: Minecraft12111Transport, + localBinder: LocalShareChannelBinder, + loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : VersionedMinecraftBridge( + transport = transport, + localBinder = localBinder, + loginAdmissionAcquire = loginAdmissionFactory?.let { factory -> + { + FabricLoginAdmissionRegistry.install(factory()) + } + }, +) { constructor() : this( VanillaMinecraft12111Transport(), NettyLocalShareChannelBinder(), @@ -31,157 +37,12 @@ class Minecraft12111Bridge internal constructor( NettyLocalShareChannelBinder(), loginAdmissionFactory, ) - - 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 admission: AutoCloseable? = null - try { - validatePublished(published).fold( - ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, - ifRight = {}, - ) - local = localBinder.bind(published.childInitializer) - validateLocal(local).fold( - ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, - ifRight = {}, - ) - published.addLocalListener(local) - localAdded = true - admission = loginAdmissionFactory - ?.invoke() - ?.let(Minecraft12111LoginAdmission::install) - val acquired = ActiveTransport(published, local, admission) - active = acquired - serverSocketAddress = local.address - LocalShareTarget(local.address) { - close(acquired) - } - } catch (failure: Throwable) { - admission?.close() - if (localAdded) { - published.removeLocalListener(checkNotNull(local)) - } - local?.close() - published.close() - throw failure - } - } - - override fun inject(): Boolean = synchronized(lifecycleLock) { - active != null - } - - override fun isInjected(): Boolean = synchronized(lifecycleLock) { - active != null - } - - override fun shutdown() { - synchronized(lifecycleLock) { - active?.stopAdmission() - active?.closeLocal() - } - } - - 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 class ActiveTransport( - private val published: PublishedMinecraftTransport, - private val local: LocalShareChannel, - private val admission: AutoCloseable?, - ) { - private var admissionStopped = false - private var localClosed = false - - fun stopAdmission() { - if (admissionStopped) { - return - } - admissionStopped = true - admission?.close() - } - - fun closeLocal() { - if (localClosed) { - return - } - localClosed = true - published.removeLocalListener(local) - local.close() - } - - fun close() { - stopAdmission() - closeLocal() - published.close() - } - } -} - -internal fun interface Minecraft12111Transport { - fun publish(options: ShareOptions): PublishedMinecraftTransport -} - -internal interface PublishedMinecraftTransport { - val address: InetSocketAddress - val childInitializer: ChannelInitializer - - fun addLocalListener(listener: LocalShareChannel) - - fun removeLocalListener(listener: LocalShareChannel) - - fun close() -} - -internal fun interface LocalShareChannelBinder { - fun bind(childInitializer: ChannelInitializer): LocalShareChannel } -internal interface LocalShareChannel { - val address: SocketAddress - - fun close() -} - -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" - } -} +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..9f730d234 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt @@ -0,0 +1,90 @@ +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.fabric.FabricLoginAdmissionRegistry +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 + +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 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, + ).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 denialReason(answer: AdmissionAnswer?): Component = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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 index 5044a4bfb..9b39bb60a 100644 --- 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 @@ -1,19 +1,13 @@ package com.minekube.connect.share.fabric.v1_21_11 -import com.minekube.connect.network.netty.LocalServerChannelWrapper 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.bootstrap.ServerBootstrap import io.netty.channel.Channel import io.netty.channel.ChannelFuture import io.netty.channel.ChannelInitializer -import io.netty.channel.DefaultEventLoopGroup -import io.netty.channel.EventLoopGroup -import io.netty.channel.local.LocalAddress -import io.netty.util.concurrent.DefaultThreadFactory import java.net.InetSocketAddress import net.minecraft.client.Minecraft import net.minecraft.client.server.IntegratedServer @@ -122,7 +116,9 @@ private class PublishedVanillaTransport( override val childInitializer: ChannelInitializer, ) : PublishedMinecraftTransport { override fun addLocalListener(listener: LocalShareChannel) { - val future = (listener as NettyLocalShareChannel).future + 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" @@ -131,7 +127,7 @@ private class PublishedVanillaTransport( } override fun removeLocalListener(listener: LocalShareChannel) { - val future = (listener as NettyLocalShareChannel).future + val future = listener.future ?: return synchronized(channels) { channels.remove(future) } @@ -146,45 +142,6 @@ private class PublishedVanillaTransport( } } -internal class NettyLocalShareChannelBinder : LocalShareChannelBinder { - override fun bind( - childInitializer: ChannelInitializer, - ): LocalShareChannel { - val eventLoop = DefaultEventLoopGroup( - 0, - DefaultThreadFactory( - "Connect Share local", - Thread.MAX_PRIORITY, - ), - ) - try { - val future = ServerBootstrap() - .channel(LocalServerChannelWrapper::class.java) - .childHandler(childInitializer) - .group(eventLoop) - .localAddress(LocalAddress.ANY) - .bind() - .syncUninterruptibly() - return NettyLocalShareChannel(future, eventLoop) - } catch (failure: Throwable) { - eventLoop.shutdownGracefully().syncUninterruptibly() - throw failure - } - } -} - -private class NettyLocalShareChannel( - val future: ChannelFuture, - private val eventLoop: EventLoopGroup, -) : LocalShareChannel { - override val address = future.channel().localAddress() - - override fun close() { - future.closeChannel() - eventLoop.shutdownGracefully().syncUninterruptibly() - } -} - private fun ChannelFuture.closeChannel() { if (channel().isOpen) { channel().close().syncUninterruptibly() 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 index e12733288..4646ddbb1 100644 --- 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 @@ -1,5 +1,6 @@ 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 diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 5a233de29..271347edd 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -48,3 +48,10 @@ dependencies { tasks.test { useJUnitPlatform() } + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} 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/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..ba53c75b3 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java @@ -0,0 +1,83 @@ +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)) { + 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) { + if (!Minecraft262LoginBridge.isPassthroughConnect(connection)) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + Minecraft262LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } +} 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..b56fd12c2 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt @@ -0,0 +1,52 @@ +package com.minekube.connect.share.fabric.v26_2 + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.google.common.collect.ArrayListMultimap +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.mojang.authlib.properties.PropertyMap +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 = ArrayListMultimap.create() + source.properties.forEach { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + val mapped = if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + properties.put(property.name, mapped) + } + GameProfile( + source.uniqueId, + source.username, + PropertyMap(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/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..427ebe151 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt @@ -0,0 +1,42 @@ +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.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 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..d9acbe402 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt @@ -0,0 +1,90 @@ +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.fabric.FabricLoginAdmissionRegistry +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 + +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 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, + ).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 denialReason(answer: AdmissionAnswer?): Component = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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..e7dba1943 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt @@ -0,0 +1,155 @@ +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 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, + ) + } 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, +) : 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/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..2fda986e2 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json @@ -0,0 +1,20 @@ +{ + "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" + ], + "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..817db758f --- /dev/null +++ b/share/fabric-26.2/src/main/resources/fabric.mod.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "connect_share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "*", + "mixins": [ + "connect-share-fabric-26.2.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "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/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..9035b2321 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt @@ -0,0 +1,95 @@ +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.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 + + 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 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, + ): 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-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt similarity index 88% rename from share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt rename to share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt index 8a7ee5988..953258697 100644 --- a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt @@ -1,13 +1,12 @@ -package com.minekube.connect.share.fabric.v1_21_11 +package com.minekube.connect.share.fabric import com.minekube.connect.share.admission.AdmissionAnswer -import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.concurrent.atomic.AtomicReference -object Minecraft12111LoginAdmission { +object FabricLoginAdmissionRegistry { private val installed = AtomicReference() fun install(gate: FabricLocalLoginAdmissionGate): AutoCloseable { From 475a8006a459fb1118af0fd4cd88de89d5782892 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:09:50 +0200 Subject: [PATCH 014/188] feat: add Connect Share host UI --- .../v1_21_11/mixin/PauseScreenMixin.java | 55 +++ .../v1_21_11/ConnectShare12111Client.kt | 65 ++++ .../fabric/v1_21_11/EndpointIdentityScreen.kt | 171 +++++++++ .../share/fabric/v1_21_11/ShareSetupScreen.kt | 110 ++++++ .../fabric/v1_21_11/ShareStatusScreen.kt | 139 ++++++++ .../assets/connect-share/lang/de_de.json | 37 ++ .../assets/connect-share/lang/en_us.json | 37 ++ .../connect-share-fabric-1.21.11.mixins.json | 3 +- .../src/main/resources/fabric.mod.json | 13 +- .../fabric/v26_2/mixin/PauseScreenMixin.java | 55 +++ .../fabric/v26_2/ConnectShare262Client.kt | 66 ++++ .../fabric/v26_2/EndpointIdentityScreen.kt | 171 +++++++++ .../share/fabric/v26_2/ShareSetupScreen.kt | 110 ++++++ .../share/fabric/v26_2/ShareStatusScreen.kt | 139 ++++++++ .../assets/connect-share/lang/de_de.json | 37 ++ .../assets/connect-share/lang/en_us.json | 37 ++ .../connect-share-fabric-26.2.mixins.json | 3 +- .../src/main/resources/fabric.mod.json | 13 +- .../share/fabric/ConnectShareClient.kt | 75 ++++ .../share/fabric/ConnectShareRuntime.kt | 50 +++ .../share/fabric/FabricShareBootstrap.kt | 184 ++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 331 ++++++++++++++++++ .../share/fabric/ConnectShareRuntimeTest.kt | 44 +++ .../share/fabric/FabricShareBootstrapTest.kt | 20 ++ .../share/fabric/ui/ShareViewModelTest.kt | 204 +++++++++++ 25 files changed, 2163 insertions(+), 6 deletions(-) create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/PauseScreenMixin.java create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt create mode 100644 share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt create mode 100644 share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt 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..af243eb23 --- /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,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..a5ae2fbf0 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt @@ -0,0 +1,65 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +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.screens.Screen + +class ConnectShare12111Client : ClientModInitializer { + override fun onInitializeClient() { + val client = Minecraft.getInstance() + val dispatcher = client.asCoroutineDispatcher() + val scope = CoroutineScope(SupervisorJob() + dispatcher) + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share"), + minecraftVersion = SharedConstants.getCurrentVersion().name(), + worldAvailable = client.hasSingleplayerServer(), + playerCount = { + client.singleplayerServer?.playerList?.playerCount ?: 0 + }, + bridgeFactory = { admission, admissionScope -> + Minecraft12111Bridge { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission(admission), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + ) + ConnectShareClient.install(installation) + + ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + ConnectShareClient.integratedWorldChanged( + minecraft.hasSingleplayerServer(), + minecraft.singleplayerServer, + ) + } + ClientLifecycleEvents.CLIENT_STOPPING.register { + ConnectShareClient.shutdown() + } + } +} 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..e00c80162 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.addFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..475821537 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt @@ -0,0 +1,110 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.CycleButton +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 ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + + override fun init() { + val current = viewModel.state.value + minecraft?.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + + addRenderableWidget(centered(title, 32)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 52, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + current.options.gameMode, + ).withValues(ShareGameMode.entries) + .create( + width / 2 - 155, + 78, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 78, + 150, + 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( + width / 2 - 75, + 110, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft?.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, 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..78170c545 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt @@ -0,0 +1,139 @@ +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.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 18)) + + val sharing = state.shareState as? ShareState.Sharing + val address = sharing?.address + ?: Component.translatable(statusKey(state.shareState)).string + addRenderableWidget( + centered( + Component.translatable("connect_share.status.address", address), + 38, + ), + ) + val copy = addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.copy")) { + sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds(width / 2 - 50, 54, 100, 20).build(), + ) + copy.active = sharing != null + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft?.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 - 100, 80, 200, 20).build(), + ) + + val pending = state.pendingAdmissions + val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 108 + index * 38 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.source.name.lowercase() + + is AdmissionIdentity.UnverifiedOffline -> "offline" + } + val label = Component.translatable( + "connect_share.status.request", + identity.name, + identity.uuid.toString(), + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ).setMaxWidth(202), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 108 + visibleRows * 38, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 116, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft?.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } +} 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..e78f182c7 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Mit Connect teilen", + "connect_share.menu.active": "Connect Share aktiv", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.setup.max_guests": "Maximale Gäste", + "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": "Connect Share", + "connect_share.status.address": "Beitrittsadresse: %s", + "connect_share.status.copy": "Adresse kopieren", + "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 · %s · %s", + "connect_share.status.allow": "Erlauben", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Warte auf Freunde…", + "connect_share.status.stop": "Teilen beenden", + "connect_share.identity.manage": "Endpunkt-Identität…", + "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." +} 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..1227e0ea9 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Share with Connect", + "connect_share.menu.active": "Connect Share active", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.setup.max_guests": "Maximum guests", + "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": "Connect Share", + "connect_share.status.address": "Join address: %s", + "connect_share.status.copy": "Copy address", + "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 · %s · %s", + "connect_share.status.allow": "Allow", + "connect_share.status.deny": "Deny", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "Waiting for friends to join…", + "connect_share.status.stop": "Stop sharing", + "connect_share.identity.manage": "Endpoint identity…", + "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." +} 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 index 12dcd00a3..1194ff0f5 100644 --- 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 @@ -12,7 +12,8 @@ "client": [ "IntegratedServerAccessor", "IntegratedServerMixin", - "LanServerPingerAccessor" + "LanServerPingerAccessor", + "PauseScreenMixin" ], "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 index 703d736bb..4217d8798 100644 --- a/share/fabric-1.21.11/src/main/resources/fabric.mod.json +++ b/share/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -1,15 +1,24 @@ { "schemaVersion": 1, - "id": "connect_share", + "id": "connect-share", "version": "${version}", "name": "Connect Share", "description": "Share a private Minecraft world through Minekube Connect.", - "environment": "*", + "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-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..f873b5b30 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java @@ -0,0 +1,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..687c08036 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt @@ -0,0 +1,66 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +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.screens.Screen + +class ConnectShare262Client : ClientModInitializer { + override fun onInitializeClient() { + val client = Minecraft.getInstance() + val scope = CoroutineScope( + SupervisorJob() + client.asCoroutineDispatcher(), + ) + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share"), + minecraftVersion = SharedConstants.getCurrentVersion().name(), + worldAvailable = client.hasSingleplayerServer(), + playerCount = { + client.singleplayerServer?.playerList?.playerCount ?: 0 + }, + bridgeFactory = { admission, admissionScope -> + Minecraft262Bridge { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission(admission), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + ) + ConnectShareClient.install(installation) + + ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + ConnectShareClient.integratedWorldChanged( + minecraft.hasSingleplayerServer(), + minecraft.singleplayerServer, + ) + } + ClientLifecycleEvents.CLIENT_STOPPING.register { + ConnectShareClient.shutdown() + } + } +} 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..3d684381a --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.addFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..ac369e2fa --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt @@ -0,0 +1,110 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.CycleButton +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 ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + + override fun init() { + val current = viewModel.state.value + minecraft.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + + addRenderableWidget(centered(title, 32)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 52, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + current.options.gameMode, + ).withValues(ShareGameMode.entries) + .create( + width / 2 - 155, + 78, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 78, + 150, + 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( + width / 2 - 75, + 110, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft.gui.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, 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..38c5c28f2 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt @@ -0,0 +1,139 @@ +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.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 18)) + + val sharing = state.shareState as? ShareState.Sharing + val address = sharing?.address + ?: Component.translatable(statusKey(state.shareState)).string + addRenderableWidget( + centered( + Component.translatable("connect_share.status.address", address), + 38, + ), + ) + val copy = addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.copy")) { + sharing?.address?.let(minecraft.keyboardHandler::setClipboard) + }.bounds(width / 2 - 50, 54, 100, 20).build(), + ) + copy.active = sharing != null + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft.gui.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 - 100, 80, 200, 20).build(), + ) + + val pending = state.pendingAdmissions + val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 108 + index * 38 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.source.name.lowercase() + + is AdmissionIdentity.UnverifiedOffline -> "offline" + } + val label = Component.translatable( + "connect_share.status.request", + identity.name, + identity.uuid.toString(), + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ).setMaxWidth(202), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 108 + visibleRows * 38, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 116, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft.gui.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } +} 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..e78f182c7 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Mit Connect teilen", + "connect_share.menu.active": "Connect Share aktiv", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.setup.max_guests": "Maximale Gäste", + "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": "Connect Share", + "connect_share.status.address": "Beitrittsadresse: %s", + "connect_share.status.copy": "Adresse kopieren", + "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 · %s · %s", + "connect_share.status.allow": "Erlauben", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Warte auf Freunde…", + "connect_share.status.stop": "Teilen beenden", + "connect_share.identity.manage": "Endpunkt-Identität…", + "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." +} 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..1227e0ea9 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Share with Connect", + "connect_share.menu.active": "Connect Share active", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.setup.max_guests": "Maximum guests", + "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": "Connect Share", + "connect_share.status.address": "Join address: %s", + "connect_share.status.copy": "Copy address", + "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 · %s · %s", + "connect_share.status.allow": "Allow", + "connect_share.status.deny": "Deny", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "Waiting for friends to join…", + "connect_share.status.stop": "Stop sharing", + "connect_share.identity.manage": "Endpoint identity…", + "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." +} 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 index 2fda986e2..4087b3bcd 100644 --- 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 @@ -12,7 +12,8 @@ "client": [ "IntegratedServerAccessor", "IntegratedServerMixin", - "LanServerPingerAccessor" + "LanServerPingerAccessor", + "PauseScreenMixin" ], "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 index 817db758f..a2f169fec 100644 --- a/share/fabric-26.2/src/main/resources/fabric.mod.json +++ b/share/fabric-26.2/src/main/resources/fabric.mod.json @@ -1,15 +1,24 @@ { "schemaVersion": 1, - "id": "connect_share", + "id": "connect-share", "version": "${version}", "name": "Connect Share", "description": "Share a private Minecraft world through Minekube Connect.", - "environment": "*", + "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-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..f4ef76355 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt @@ -0,0 +1,75 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.fabric.ui.ShareViewModel + +fun interface ConnectShareScreenFactory { + fun open(parent: Any, active: Boolean) +} + +data class ConnectShareInstallation( + val viewModel: ShareViewModel, + val runtime: ConnectShareRuntime, + val screens: ConnectShareScreenFactory, +) + +object ConnectShareClient { + @Volatile + private var installation: ConnectShareInstallation? = null + + 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 openPauseScreen(parent: Any) { + installation?.let { installed -> + installed.screens.open(parent, isShareActive()) + } + } + + @JvmStatic + fun viewModel(): ShareViewModel = + checkNotNull(installation).viewModel + + @JvmStatic + fun integratedWorldChanged( + worldAvailable: Boolean, + identity: Any?, + ) { + installation?.runtime?.integratedWorldChanged(worldAvailable, identity) + } + + @JvmStatic + fun shutdown() { + installation?.runtime?.shutdown() + } + + 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 + } +} 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..54839afba --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt @@ -0,0 +1,50 @@ +package com.minekube.connect.share.fabric + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +class ConnectShareRuntime( + private val scope: CoroutineScope, + private val stopShare: suspend () -> Unit, + private val worldAvailabilityChanged: (Boolean) -> Unit = {}, +) { + private val lock = Any() + private var currentWorldIdentity: Any? = null + + fun integratedWorldChanged( + worldAvailable: Boolean, + identity: Any? = if (worldAvailable) DEFAULT_WORLD_IDENTITY else null, + ) { + val shouldStop = synchronized(lock) { + val previous = currentWorldIdentity + currentWorldIdentity = if (worldAvailable) identity else null + previous != null && + (!worldAvailable || previous != currentWorldIdentity) + } + worldAvailabilityChanged(worldAvailable) + if (shouldStop) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + stopShare() + } + } + } + + fun shutdown() { + val shouldStop = synchronized(lock) { + (currentWorldIdentity != null).also { + currentWorldIdentity = null + } + } + worldAvailabilityChanged(false) + if (shouldStop) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + stopShare() + } + } + } + + private companion object { + val DEFAULT_WORLD_IDENTITY = Any() + } +} 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..0d8e80c7c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt @@ -0,0 +1,184 @@ +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.VersionedMinecraftBridge +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.fabric.ui.ShareViewModel +import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +import com.minekube.connect.share.identity.EndpointIdentityStore +import com.minekube.connect.util.MessageFormatter +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicReference +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient + +object FabricShareBootstrap { + fun create( + scope: CoroutineScope, + dataDirectory: Path, + minecraftVersion: String, + worldAvailable: Boolean, + playerCount: () -> Int, + bridgeFactory: + (AdmissionController, CoroutineScope) -> VersionedMinecraftBridge, + screens: ConnectShareScreenFactory, + environment: Map = System.getenv(), + logger: ConnectLogger = FabricConnectLogger(), + httpClient: OkHttpClient = OkHttpClient(), + ): ConnectShareInstallation { + val viewModelReference = AtomicReference() + val admission = AdmissionController( + scope = scope, + connectedCount = { + (playerCount() - HOST_PLAYER_COUNT).coerceAtLeast(0) + }, + maxGuests = { + viewModelReference.get()?.state?.value?.options?.maxGuests + ?: DEFAULT_MAX_GUESTS + }, + ) + val bridge = bridgeFactory(admission, scope) + val identityStore = EndpointIdentityStore( + directory = dataDirectory, + environment = environment, + endpointNames = RandomEndpointNameSource(httpClient), + tokenStore = EndpointTokenStore(), + ) + val validator = WatchEndpointCredentialValidator( + client = httpClient, + watchUrl = watchHttpUrl(environment), + timeout = 10.seconds, + ) + val ingress = FabricConnectIngress( + dataDirectory = dataDirectory, + platformInjector = bridge, + logger = logger, + platformUtils = FabricPlatformUtils( + minecraftVersion = minecraftVersion, + playerCount = playerCount, + ), + admission = admission, + scope = scope, + ) + val coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityStore::currentOrCreate, + admission = admission, + failureReporter = logger::warn, + ) + val viewModel = ShareViewModel( + scope = scope, + shareState = coordinator.state, + pendingAdmissions = admission.pending, + initialWorldAvailable = worldAvailable, + identityActions = StoredEndpointIdentityUiActions( + store = identityStore, + validator = validator, + ), + startShare = coordinator::start, + stopShare = coordinator::stop, + answerAdmission = admission::answer, + ) + viewModelReference.set(viewModel) + val runtime = ConnectShareRuntime( + scope = scope, + stopShare = { + coordinator.worldReplaced() + }, + worldAvailabilityChanged = viewModel::setWorldAvailable, + ) + return ConnectShareInstallation( + viewModel = viewModel, + runtime = runtime, + screens = screens, + ) + } + + internal fun watchHttpUrl(environment: Map) = + normalizeWebSocketScheme( + environment[WATCH_URL_ENV] ?: DEFAULT_WATCH_URL, + ).toHttpUrlOrNull() + ?: normalizeWebSocketScheme(DEFAULT_WATCH_URL).toHttpUrl() + + 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 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/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt new file mode 100644 index 000000000..c25fb9128 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt @@ -0,0 +1,331 @@ +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 java.nio.file.Path +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +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 identity: EndpointIdentitySummary? = null, + val importDraft: IdentityImportDraft = IdentityImportDraft(), + val operationInProgress: Boolean = false, + val safeMessage: String? = 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, + private val startShare: + suspend (ShareOptions) -> Either, + private val stopShare: suspend () -> Either, + private val answerAdmission: (UUID, Boolean) -> Unit, +) { + private val mutableState = MutableStateFlow( + ShareUiState( + worldAvailable = initialWorldAvailable, + shareState = shareState.value, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + pendingAdmissions = pendingAdmissions.value, + ), + ) + + 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(start = CoroutineStart.UNDISPATCHED) { + 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 start() { + if (!state.value.startEnabled) return + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + startShare(state.value.options).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + } + } + + fun stop() { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + stopShare().fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + } + } + + fun allow(requestId: UUID) { + answerAdmission(requestId, true) + } + + fun deny(requestId: UUID) { + answerAdmission(requestId, false) + } + + fun setImportEndpoint(endpoint: String) { + update { + if (!importDraft.endpointEditable) { + this + } else { + copy(importDraft = importDraft.copy(endpoint = endpoint)) + } + } + } + + fun setImportToken(token: String) { + update { + if (!importDraft.tokenEditable) { + this + } else { + copy(importDraft = importDraft.copy(token = token)) + } + } + } + + fun importIdentity() { + val draft = state.value.importDraft + if (!draft.endpointEditable || !draft.tokenEditable) { + update { copy(safeMessage = MANAGED_MESSAGE) } + return + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + applyIdentityResult( + identityActions.import(draft.endpoint, draft.token), + ) + } + } + } + + fun importTokenFile(tokenFile: Path) { + val draft = state.value.importDraft + if (!draft.endpointEditable || !draft.tokenEditable) { + update { copy(safeMessage = MANAGED_MESSAGE) } + return + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + applyIdentityResult( + identityActions.importTokenFile(draft.endpoint, tokenFile), + ) + } + } + } + + fun resetIdentity() { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + applyIdentityResult(identityActions.reset()) + } + } + } + + private fun applyIdentityResult( + result: Either, + ) { + result.fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { identity -> + update { + copy( + identity = identity, + importDraft = IdentityImportDraft() + .withEditability(identity), + safeMessage = null, + ) + } + }, + ) + } + + private suspend fun runOperation(operation: suspend () -> Unit) { + 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 update(transform: ShareUiState.() -> ShareUiState) { + mutableState.value = mutableState.value.transform() + } + + private fun IdentityImportDraft.withEditability( + identity: EndpointIdentitySummary, + ): IdentityImportDraft = copy( + endpointEditable = !identity.endpointManagedByEnvironment, + tokenEditable = !identity.tokenManagedByEnvironment, + ) + + private companion object { + const val MANAGED_MESSAGE = + "Connect credentials are managed by the environment" + const val GENERIC_FAILURE_MESSAGE = + "Could not update Connect Share" + } +} + +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/ConnectShareRuntimeTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt new file mode 100644 index 000000000..fb947fb2a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt @@ -0,0 +1,44 @@ +package com.minekube.connect.share.fabric + +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ConnectShareRuntimeTest { + @Test + fun `leaving a world stops the active share exactly once`() = runTest { + var stopCalls = 0 + val runtime = ConnectShareRuntime( + scope = backgroundScope, + stopShare = { + stopCalls++ + }, + ) + + 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 = backgroundScope, + stopShare = { + stopCalls++ + }, + ) + + runtime.integratedWorldChanged(worldAvailable = true, identity = "one") + runtime.integratedWorldChanged(worldAvailable = true, identity = "two") + advanceUntilIdle() + + assertEquals(1, stopCalls) + } +} 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..04761ce0e --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt @@ -0,0 +1,20 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertEquals + +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(), + ) + } +} 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..29eabbf0b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt @@ -0,0 +1,204 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +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 java.nio.file.Path +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +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 `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 credentials are managed by the environment", + viewModel.state.value.safeMessage, + ) + } + + @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, + ) + } + + private fun TestScope.viewModel( + shareState: MutableStateFlow = + MutableStateFlow(ShareState.Idle), + pending: MutableStateFlow> = + MutableStateFlow(emptyList()), + worldAvailable: Boolean = true, + identityActions: EndpointIdentityUiActions = + FakeIdentityActions(localIdentity()), + answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, + ) = ShareViewModel( + scope = backgroundScope, + shareState = shareState, + pendingAdmissions = pending, + initialWorldAvailable = worldAvailable, + identityActions = identityActions, + startShare = { options -> + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "${options.maxGuests}.example.test", + ), + ) + }, + 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")) + } +} From 1f76ea648203e0a38fe481b4349e4713e76a78e2 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:21:59 +0200 Subject: [PATCH 015/188] build: isolate Connect Share networking runtime --- .../connect.shadow-conventions.gradle.kts | 27 ++++ .../connect/tunnel/p2p/Libp2pRuntime.java | 8 + .../tunnel/p2p/Libp2pRuntimeLoader.java | 143 +++++++++++++++++- .../p2p/Libp2pRuntimeLoaderPayloadTest.java | 31 ++++ share/fabric-1.21.11/build.gradle.kts | 70 +++++++++ .../v1_21_11/Fabric12111ArtifactTest.kt | 129 ++++++++++++++++ share/fabric-26.2/build.gradle.kts | 72 +++++++++ .../fabric/v26_2/Fabric262ArtifactTest.kt | 129 ++++++++++++++++ .../share/fabric/FabricConnectIngress.kt | 9 +- .../share/fabric/SecretRedactionTest.kt | 43 ++++++ 10 files changed, 652 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoaderPayloadTest.java create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SecretRedactionTest.kt 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 41a15884f..64d66a52d 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,20 @@ 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", + ) + } named("build") { dependsOn(shadowJar) } 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..b27c4ece7 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.", @@ -46,32 +55,81 @@ final class Libp2pRuntimeLoader { "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<>(); + codeSourceUrl().ifPresent(urls::add); + urls.add(payload.toUri().toURL()); + 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 +139,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 +214,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/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/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 7d7c82a1f..832ce7ba2 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -1,4 +1,7 @@ +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") } @@ -8,6 +11,8 @@ base { } java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 toolchain { languageVersion = JavaLanguageVersion.of(21) } @@ -31,6 +36,11 @@ repositories { } } +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + dependencies { minecraft("com.mojang:minecraft:1.21.11") mappings(loom.officialMojangMappings()) @@ -41,6 +51,12 @@ dependencies { 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") + } testImplementation(kotlin("test")) testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -48,6 +64,11 @@ dependencies { tasks.test { useJUnitPlatform() + dependsOn(tasks.remapJar) + systemProperty( + "connectShareArtifact", + tasks.remapJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) } tasks.processResources { @@ -56,3 +77,52 @@ tasks.processResources { 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.nukkitx.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") +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")) +} +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") + } +} + +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("") +} 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..b1106c6cb --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt @@ -0,0 +1,129 @@ +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.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 `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( + 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("kotlin/") }) + + 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/") }) + } + } + + @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, + ) + assertTrue(host.classLoader === runtimeLoader) + } 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, + 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-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 271347edd..92915b131 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -1,4 +1,7 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + plugins { + id("connect.shadow-conventions") id("net.fabricmc.fabric-loom") id("org.jetbrains.kotlin.jvm") } @@ -8,6 +11,8 @@ base { } java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 toolchain { languageVersion = JavaLanguageVersion.of(25) } @@ -31,6 +36,11 @@ repositories { } } +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + dependencies { minecraft("com.mojang:minecraft:26.2") implementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") @@ -40,6 +50,12 @@ dependencies { 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") + } testImplementation(kotlin("test")) testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -55,3 +71,59 @@ tasks.processResources { 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.nukkitx.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") +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")) +} +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") + } +} + +tasks.assemble { + dependsOn(connectShareJar) +} + +tasks.test { + dependsOn(connectShareJar) + systemProperty( + "connectShareArtifact", + connectShareJar.flatMap { it.archiveFile } + .get() + .asFile + .absolutePath, + ) +} 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..2532610aa --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt @@ -0,0 +1,129 @@ +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.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 Fabric262ArtifactTest { + @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( + 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("kotlin/") }) + + 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/") }) + } + } + + @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, + ) + assertTrue(host.classLoader === runtimeLoader) + } 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, + 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 { + val FORBIDDEN_TYPE_PREFIXES = listOf( + "io.libp2p.", + "io.netty.", + ) + } +} 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 index b9ec3d0e1..9fcc9b143 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -164,13 +165,19 @@ private class GuiceFabricConnectRuntimeFactory( ) } return FabricConnectRuntime { - platform.disable() + try { + platform.disable() + } finally { + Libp2pRuntime.close() + } } } catch (failure: Throwable) { try { platform.disable() } catch (cleanupFailure: Throwable) { failure.addSuppressed(cleanupFailure) + } finally { + Libp2pRuntime.close() } throw failure } 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)) + } + } +} From fba5afddfee322ab8c58a95a635c6f0a451468b4 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:24:08 +0200 Subject: [PATCH 016/188] ci: verify Connect Share Fabric artifacts --- .github/workflows/pullrequest.yml | 68 ++++++++++++++++++++ README.md | 22 +++++++ docs/connect-share-testing.md | 102 ++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 docs/connect-share-testing.md diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index cb335fde3..cb47ea70a 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -59,3 +59,71 @@ jobs: with: name: Connect Velocity path: velocity/build/libs/connect-velocity.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/README.md b/README.md index 4144a1270..832e00041 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,28 @@ low latency edge proxies network nearest to you. Please refer to https://connect.minekube.com for more documentation. +## Connect Share Fabric mod + +Connect Share is an in-development client-side Fabric mod for Minecraft Java +1.21.11 and 26.2. It shares a singleplayer world through the normal Connect +network without exposing Minecraft's LAN listener to the local network. + +The first slice provides: + +- a native **Share with Connect** flow in the pause menu; +- one persistent endpoint identity reused across worlds and restarts; +- import of an existing dashboard endpoint and token, including `token.json`; +- `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; +- a stable `*.play.minekube.net` address for unmodified Java clients; +- host approval before each new guest reaches the world; +- support for both authenticated and offline-mode guests; and +- isolated, self-contained Fabric artifacts for both supported game versions. + +The mod artifacts have their own build and acceptance process. They are not part +of the stable proxy/plugin release workflow. See +[docs/connect-share-testing.md](docs/connect-share-testing.md) for the manual +singleplayer acceptance pass. + ## Integrating with login / auth plugins Connect authenticates players at the edge, so login plugins that force online mode on a diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md new file mode 100644 index 000000000..176b54304 --- /dev/null +++ b/docs/connect-share-testing.md @@ -0,0 +1,102 @@ +# Connect Share singleplayer acceptance + +Connect Share is built separately for Minecraft Java 1.21.11 on Java 21 and +Minecraft Java 26.2 on Java 25. Run this pass against both artifacts before +calling the singleplayer slice 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-21-11:build +./gradlew :share:fabric-26-2:build +``` + +Use the unclassified versioned JAR in each module's `build/libs` directory. +Do not install `sources`, `dev`, `unshaded`, or `parent-shadow` artifacts. +Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. + +## Identity reuse and import + +1. Start a singleplayer world and choose **Share with Connect**. +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, including any + hostname or custom-domain configuration attached to it. +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. + +## 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. + +## Artifact inspection + +Inspect the final JARs: + +```sh +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 +``` + +Each final artifact must contain: + +- `fabric.mod.json`; +- the version-specific Connect Share mixin JSON; +- 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. + +## 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. From b00b02d60869614c4d3b9f0447a154c0add5ec1e Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:28:38 +0200 Subject: [PATCH 017/188] docs: plan Connect Share direct P2P --- .../2026-07-30-connect-share-direct-p2p.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md 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..41b5f68cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -0,0 +1,82 @@ +# 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. +- Peer identities, capabilities, invitations, and approvals are ephemeral per + share. The Connect endpoint token remains the only persistent network secret. + +## 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, ephemeral 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/QUIC, 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 multiplayer/Join Share UI is open and remove it on + close. +- Add native Minecraft Join 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. From bab67d3e36af02d88496e435d49b4f9ee7961bf5 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:50:19 +0200 Subject: [PATCH 018/188] feat: add isolated Connect Share direct transport --- .../connect/tunnel/p2p/DirectP2pAuthMode.java | 28 + .../tunnel/p2p/DirectP2pDiscoveredShare.java | 66 ++ .../p2p/DirectP2pDiscoveryListener.java | 28 + .../tunnel/p2p/DirectP2pHostConfig.java | 74 ++ .../tunnel/p2p/DirectP2pHostHandler.java | 30 + .../connect/tunnel/p2p/DirectP2pHostInfo.java | 74 ++ .../connect/tunnel/p2p/DirectP2pNode.java | 184 ++++ .../tunnel/p2p/DirectP2pNodeRuntime.java | 966 ++++++++++++++++++ .../connect/tunnel/p2p/DirectP2pProxy.java | 52 + .../connect/tunnel/p2p/DirectP2pSession.java | 52 + .../tunnel/p2p/Libp2pRuntimeLoader.java | 9 + .../connect/tunnel/p2p/DirectP2pNodeTest.java | 219 ++++ .../connect/share/direct/ShareInviteCodec.kt | 345 +++++++ .../connect/share/direct/TransportSelector.kt | 60 ++ .../share/direct/ShareInviteCodecTest.kt | 122 +++ .../share/direct/TransportSelectorTest.kt | 91 ++ 16 files changed, 2400 insertions(+) create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pAuthMode.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveredShare.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveryListener.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostConfig.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostHandler.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostInfo.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pProxy.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java create mode 100644 core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/direct/TransportSelector.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/direct/TransportSelectorTest.kt 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..d83017cd3 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -0,0 +1,184 @@ +/* + * 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.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 startHost; + private Method sign; + private Method publish; + private Method inspect; + private Method startDiscovery; + private Method openProxy; + private Method close; + + public DirectP2pNode() { + try { + Class runtimeClass = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + true, + Libp2pRuntimeLoader.classLoader()); + java.lang.reflect.Constructor constructor = + runtimeClass.getDeclaredConstructor(); + constructor.setAccessible(true); + runtime = constructor.newInstance(); + startHost = accessible(runtimeClass.getDeclaredMethod( + "startHost", + DirectP2pHostConfig.class, + DirectP2pHostHandler.class)); + sign = accessible(runtimeClass.getDeclaredMethod("sign", byte[].class)); + publish = accessible(runtimeClass.getDeclaredMethod( + "publish", + 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 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 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..b2108ca82 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -0,0 +1,966 @@ +/* + * 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.MDnsDiscovery; +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.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +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.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; +import kotlin.Unit; + +/** + * 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 java.util.Set discoveredInvitations = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + private Host host; + private DirectP2pHostConfig hostConfig; + private DirectP2pHostHandler hostHandler; + private volatile String invitation; + private MDnsDiscovery discovery; + private DirectP2pDiscoveryListener discoveryListener; + private boolean started; + private boolean closed; + + DirectP2pNodeRuntime() { + Pair pair = KeyKt.generateKeyPair(KeyType.ED25519); + this.privateKey = pair.getFirst(); + } + + synchronized DirectP2pHostInfo startHost( + DirectP2pHostConfig config, + DirectP2pHostHandler handler) { + ensureOpen(); + if (hostConfig != null) { + throw new IllegalStateException("Connect Share direct host is already started"); + } + hostConfig = Objects.requireNonNull(config, "config"); + hostHandler = Objects.requireNonNull(handler, "handler"); + host = Libp2pTunnelTransportRuntime.createHost( + privateKey, + "/ip4/0.0.0.0/tcp/0"); + installProtocols(host); + startHostIfNeeded(); + + 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(); + if (hostConfig == null) { + throw new IllegalStateException("Connect Share direct host is not started"); + } + return privateKey.sign(Arrays.copyOf(payload, payload.length)); + } + + synchronized void publish(String invitation) { + ensureOpen(); + if (hostConfig == null || host == null) { + throw new IllegalStateException("Connect Share direct host is not started"); + } + if (this.invitation != null) { + throw new IllegalStateException("Connect Share invitation is already published"); + } + this.invitation = requireInvitation(invitation); + 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); + try { + ProxyRuntime proxy = new ProxyRuntime( + host, + address, + new DirectPreface(shareId, capability, authMode), + timeout); + proxies.add(proxy); + proxy.start(); + return new DirectP2pProxy(proxy.localAddress(), () -> { + proxy.close(); + proxies.remove(proxy); + }); + } catch (IOException e) { + throw new IllegalStateException("Could not bind the direct Minecraft proxy", e); + } + } + + synchronized void close() { + if (closed) { + return; + } + closed = true; + if (discovery != null) { + await(discovery.stop(), START_TIMEOUT_SECONDS, "stop Connect Share LAN discovery"); + 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; + } + discovery = new MDnsDiscovery( + host, + MDNS_SERVICE, + MDNS_QUERY_INTERVAL_SECONDS, + null); + discovery.addHandler(peer -> { + onMdnsPeer(peer); + return Unit.INSTANCE; + }); + await(discovery.start(), START_TIMEOUT_SECONDS, "start Connect Share LAN discovery"); + } + + 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 (discoveredInvitations.add(found.invitation())) { + 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(); + } + + 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, + 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 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 = invitation; + 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() { + Thread thread = new Thread(this::acceptAndDial, "connect-share-direct-guest"); + thread.setDaemon(true); + thread.start(); + } + + private void acceptAndDial() { + try { + client = listener.accept(); + listener.close(); + 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"); + 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/DirectP2pSession.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java new file mode 100644 index 000000000..d67e35806 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.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.util.Objects; + +public final class DirectP2pSession { + private final String peerId; + private final DirectP2pAuthMode authMode; + private final String connectionId; + + public DirectP2pSession( + String peerId, + DirectP2pAuthMode authMode, + String connectionId) { + this.peerId = Objects.requireNonNull(peerId, "peerId"); + this.authMode = Objects.requireNonNull(authMode, "authMode"); + this.connectionId = Objects.requireNonNull(connectionId, "connectionId"); + } + + public String peerId() { + return peerId; + } + + public DirectP2pAuthMode authMode() { + return authMode; + } + + public String connectionId() { + return connectionId; + } +} 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 b27c4ece7..0e237fb5c 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 @@ -50,6 +50,15 @@ 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.DirectP2pSession", "com.minekube.connect.tunnel.p2p.Libp2pEndpoint", "com.minekube.connect.tunnel.p2p.Libp2pRuntime", "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", 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..d48df36f7 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -0,0 +1,219 @@ +/* + * 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.List; +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; + +class DirectP2pNodeTest { + 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()); + assertFalse(session.get().peerId().isBlank()); + assertFalse(session.get().connectionId().isBlank()); + } + } + + @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 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"); + + 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-secret-payload", + discovered.invitation()); + assertFalse(discovered.toString().contains("signed-secret-payload")); + assertFalse(discovered.toString().contains(hostInfo.lanAddresses().get(0))); + } + + @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, + 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/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..928a4a3db --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt @@ -0,0 +1,345 @@ +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, +) { + 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 + + 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() + return result + } + + override fun toString(): String = + "ShareInvitePayload(wireVersion=$wireVersion, shareId=$shareId, " + + "expiresAtEpochMillis=$expiresAtEpochMillis, " + + "connectAddress=$connectAddress, peerId=$peerId, " + + "internetDirectEnabled=$internetDirectEnabled, " + + "directCandidates=, capability=)" +} + +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" + } +} + +object ShareInviteCodec { + const val WIRE_VERSION = 1 + 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 FIELD_COUNT = 10 + private const val UNSIGNED_FIELD_COUNT = 9 + + fun encode(invite: SignedShareInvite): String { + val writer = CborWriter() + writer.array(FIELD_COUNT) + 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 { + array(UNSIGNED_FIELD_COUNT) + 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 == WIRE_VERSION) { + ShareInviteError.UnsupportedVersion(parsed.payload.wireVersion) + } + ensure(parsed.payload.expiresAtEpochMillis >= now.toEpochMilli()) { + ShareInviteError.Expired + } + ensure(parsed.payload.directCandidates.none(::isRelayAddress)) { + ShareInviteError.RelayCandidateForbidden + } + 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 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) + } + + 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 { + require(readLength(4) == FIELD_COUNT) + val payload = ShareInvitePayload( + wireVersion = unsigned().toInt(), + shareId = UUID.fromString(text()), + expiresAtEpochMillis = unsigned(), + connectAddress = nullableText(), + peerId = text(), + internetDirectEnabled = bool(), + directCandidates = List(readLength(4)) { text() }, + capability = text(), + ) + 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/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..c06fd5d39 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt @@ -0,0 +1,122 @@ +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 `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) + } + + private fun payload( + wireVersion: Int = ShareInviteCodec.WIRE_VERSION, + expiresAt: Long = NOW + 60_000, + 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, + ) + + 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) + } +} From 3507448d544aab66eeb422d1113ebd5caa46584c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:12:51 +0200 Subject: [PATCH 019/188] feat: complete Connect Share direct joining --- .../tunnel/p2p/DirectP2pNodeRuntime.java | 90 ++++-- .../connect/tunnel/p2p/DirectP2pRoute.java | 28 ++ .../connect/tunnel/p2p/DirectP2pSession.java | 7 + .../tunnel/p2p/Libp2pRuntimeLoader.java | 1 + .../connect/tunnel/p2p/DirectP2pNodeTest.java | 2 + .../connect/share/CapturedServerTransport.kt | 14 +- .../connect/share/DirectShareIngress.kt | 35 +++ .../connect/share/ShareCoordinator.kt | 64 +++- .../minekube/connect/share/ShareOptions.kt | 1 + .../com/minekube/connect/share/ShareState.kt | 17 +- .../share/direct/DirectSessionRegistry.kt | 65 ++++ .../connect/share/direct/ShareInviteCodec.kt | 19 ++ .../connect/share/ShareCoordinatorTest.kt | 132 +++++++++ .../share/direct/DirectSessionRegistryTest.kt | 68 +++++ .../share/direct/ShareInviteCodecTest.kt | 17 ++ .../mixin/ServerLoginPacketListenerMixin.java | 24 +- .../v1_21_11/mixin/TitleScreenMixin.java | 30 ++ .../v1_21_11/ConnectShare12111Client.kt | 13 + .../v1_21_11/Minecraft12111LoginBridge.kt | 72 +++++ .../share/fabric/v1_21_11/ShareJoinScreen.kt | 265 +++++++++++++++++ .../share/fabric/v1_21_11/ShareSetupScreen.kt | 20 ++ .../fabric/v1_21_11/ShareStatusScreen.kt | 69 ++++- .../assets/connect-share/lang/de_de.json | 19 +- .../assets/connect-share/lang/en_us.json | 19 +- .../connect-share-fabric-1.21.11.mixins.json | 3 +- .../v1_21_11/CapturedServerTransportTest.kt | 12 +- .../mixin/ServerLoginPacketListenerMixin.java | 24 +- .../fabric/v26_2/mixin/TitleScreenMixin.java | 30 ++ .../fabric/v26_2/ConnectShare262Client.kt | 13 + .../fabric/v26_2/Minecraft262LoginBridge.kt | 72 +++++ .../share/fabric/v26_2/ShareJoinScreen.kt | 260 ++++++++++++++++ .../share/fabric/v26_2/ShareSetupScreen.kt | 20 ++ .../share/fabric/v26_2/ShareStatusScreen.kt | 69 ++++- .../assets/connect-share/lang/de_de.json | 19 +- .../assets/connect-share/lang/en_us.json | 19 +- .../connect-share-fabric-26.2.mixins.json | 3 +- .../share/fabric/ConnectShareClient.kt | 85 ++++++ .../FabricDirectAuthenticationPolicy.kt | 25 ++ .../share/fabric/FabricDirectShareIngress.kt | 207 +++++++++++++ .../fabric/FabricLoginAdmissionRegistry.kt | 3 + .../fabric/FabricSessionAdmissionGate.kt | 5 +- .../share/fabric/FabricShareBootstrap.kt | 7 + .../share/fabric/FabricShareBrowser.kt | 280 ++++++++++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 6 + .../FabricDirectAuthenticationPolicyTest.kt | 28 ++ .../fabric/FabricDirectShareIngressTest.kt | 178 +++++++++++ .../FabricLocalLoginAdmissionGateTest.kt | 3 + .../share/fabric/FabricShareBrowserTest.kt | 184 ++++++++++++ .../share/fabric/GuestConnectionLeaseTest.kt | 63 ++++ 49 files changed, 2607 insertions(+), 102 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pRoute.java create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/direct/DirectSessionRegistry.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/direct/DirectSessionRegistryTest.kt create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/GuestConnectionLeaseTest.kt 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 index b2108ca82..07488f856 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -224,20 +224,29 @@ synchronized DirectP2pProxy openProxy( } ensureGuestHost(false); + ProxyRuntime proxy = null; try { - ProxyRuntime proxy = new ProxyRuntime( + proxy = new ProxyRuntime( host, address, new DirectPreface(shareId, capability, authMode), timeout); - proxies.add(proxy); proxy.start(); + proxies.add(proxy); + ProxyRuntime active = proxy; return new DirectP2pProxy(proxy.localAddress(), () -> { - proxy.close(); - proxies.remove(proxy); + active.close(); + proxies.remove(active); }); - } catch (IOException e) { - throw new IllegalStateException("Could not bind the direct Minecraft proxy", e); + } catch (Exception e) { + if (proxy != null) { + proxy.close(); + } + throw e instanceof RuntimeException + ? (RuntimeException) e + : new IllegalStateException( + "Could not bind the direct Minecraft proxy", + e); } } @@ -346,6 +355,7 @@ private void accept(Stream stream, DirectPreface preface) { 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()) { @@ -375,6 +385,27 @@ private static int listenTcpPort(Host host) { 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) { @@ -812,36 +843,37 @@ private InetSocketAddress localAddress() { } private void start() { - Thread thread = new Thread(this::acceptAndDial, "connect-share-direct-guest"); + 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 acceptAndDial() { + private void acceptAndBridge() { try { client = listener.accept(); listener.close(); - 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"); SocketBridge.install( stream, client, 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 index d67e35806..758a5962d 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java @@ -27,14 +27,17 @@ 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"); } @@ -46,6 +49,10 @@ 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/Libp2pRuntimeLoader.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java index 0e237fb5c..48d20db07 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 @@ -58,6 +58,7 @@ final class Libp2pRuntimeLoader { "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", 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 index d48df36f7..db16660b8 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -112,6 +112,7 @@ void twoLoopbackNodesExchangeMinecraftShapedBytes() throws Exception { 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()); } @@ -197,6 +198,7 @@ void parentBoundaryUsesOnlyJdkTypes() { DirectP2pDiscoveredShare.class, DirectP2pDiscoveryListener.class, DirectP2pProxy.class, + DirectP2pRoute.class, DirectP2pAuthMode.class); for (Class type : boundary) { 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 index cbecf0d0d..be28ccc49 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -3,6 +3,8 @@ 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 @@ -29,12 +31,20 @@ object CapturedServerTransport { 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 = initializer + ?.childInitializer = wrapped } - return initializer + return wrapped } @JvmStatic 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..1cb010ba1 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt @@ -0,0 +1,35 @@ +package com.minekube.connect.share + +import java.net.SocketAddress + +class DirectShareHandle( + val invitation: String, + val lanAvailable: Boolean, + val internetAvailable: Boolean, + val close: suspend () -> Unit, +) { + fun copy( + invitation: String = this.invitation, + lanAvailable: Boolean = this.lanAvailable, + internetAvailable: Boolean = this.internetAvailable, + close: suspend () -> Unit = this.close, + ) = DirectShareHandle( + invitation = invitation, + 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/ShareCoordinator.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt index 6de783adb..79ce59a12 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -23,6 +23,7 @@ class ShareCoordinator( 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() @@ -49,17 +50,57 @@ class ShareCoordinator( acquire = { bridge.open(options) }, release = { acquired, _ -> acquired.close() }, ) - val identity = identityProvider() - val connect = install( - acquire = { ingress.start(identity, target.address) }, - release = { acquired, _ -> acquired.close() }, - ) - AcquiredShare(target, connect) + 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.address, + 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, + 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) mutableState.value = sharing @@ -123,7 +164,8 @@ class ShareCoordinator( private data class AcquiredShare( val target: LocalShareTarget, - val connect: ConnectShareHandle, + val connect: ConnectShareHandle?, + val direct: DirectShareHandle?, ) private data class ActiveShare( @@ -163,5 +205,9 @@ class ShareCoordinator( 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 index b898bc470..a11383a78 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt @@ -4,6 +4,7 @@ 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) { 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 index 0a623d2e8..18fdcf153 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt @@ -5,9 +5,20 @@ sealed interface ShareState { data object Starting : ShareState data class Sharing( - val endpoint: String, - val address: String, - ) : ShareState + 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 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 index 928a4a3db..539ae798d 100644 --- 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 @@ -99,6 +99,11 @@ sealed interface ShareInviteError { 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 { @@ -153,6 +158,13 @@ object ShareInviteCodec { ensure(parsed.payload.directCandidates.none(::isRelayAddress)) { ShareInviteError.RelayCandidateForbidden } + ensure( + parsed.payload.directCandidates.all { + candidatePeerId(it) == parsed.payload.peerId + }, + ) { + ShareInviteError.PeerMismatch + } parsed } } @@ -174,6 +186,13 @@ object ShareInviteCodec { 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()) 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 index afe3acae6..a2c8c9a03 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -71,6 +71,90 @@ class ShareCoordinatorTest { 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 `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() @@ -107,6 +191,37 @@ class ShareCoordinatorTest { 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() @@ -213,6 +328,11 @@ class ShareCoordinatorTest { ingressClose: suspend () -> Unit = { events += "ingress-close" }, + directStart: (suspend ( + ShareOptions, + java.net.SocketAddress, + String?, + ) -> DirectShareHandle)? = null, failureReporter: (String) -> Unit = {}, ): Fixture { val admission = AdmissionController( @@ -235,12 +355,18 @@ class ShareCoordinatorTest { 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, @@ -258,6 +384,12 @@ class ShareCoordinatorTest { 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", 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 index c06fd5d39..64829b1a9 100644 --- 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 @@ -85,6 +85,23 @@ class ShareInviteCodecTest { 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) + } + private fun payload( wireVersion: Int = ShareInviteCodec.WIRE_VERSION, expiresAt: Long = NOW + 60_000, 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 index 15b6d5232..e68d19668 100644 --- 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 @@ -59,7 +59,8 @@ public abstract class ServerLoginPacketListenerMixin { private void connectShare$awaitPassthroughAdmission( GameProfile profile, CallbackInfo callback) { - if (!Minecraft12111LoginBridge.isPassthroughConnect(connection)) { + boolean direct = Minecraft12111LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft12111LoginBridge.isPassthroughConnect(connection)) { return; } if (connectShare$admissionAllowed) { @@ -71,11 +72,20 @@ public abstract class ServerLoginPacketListenerMixin { return; } connectShare$admissionStarted = true; - Minecraft12111LoginBridge.requestPassthroughAdmission( - connection, - server, - profile, - () -> connectShare$admissionAllowed = true, - this::disconnect); + 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..05db86ab3 --- /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,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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 index a5ae2fbf0..d65b6cd5f 100644 --- 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 @@ -29,6 +29,10 @@ class ConnectShare12111Client : ClientModInitializer { playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, + worldDisplayName = { + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world" + }, bridgeFactory = { admission, admissionScope -> Minecraft12111Bridge { FabricLocalLoginAdmissionGate( @@ -49,6 +53,12 @@ class ConnectShare12111Client : ClientModInitializer { ) } }, + guestScreens = { parent -> + val parentScreen = parent as Screen + client.execute { + client.setScreen(ShareJoinScreen(parentScreen)) + } + }, ) ConnectShareClient.install(installation) @@ -57,6 +67,9 @@ class ConnectShare12111Client : ClientModInitializer { minecraft.hasSingleplayerServer(), minecraft.singleplayerServer, ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() 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 index 9f730d234..a23ec7a7b 100644 --- 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 @@ -4,7 +4,13 @@ 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.DirectOnlineAuthenticationRequired +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +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 @@ -40,6 +46,10 @@ object Minecraft12111LoginBridge { .map { it.player.auth.isPassthrough } .orElse(false) + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + @JvmStatic fun requestPassthroughAdmission( connection: Connection, @@ -60,6 +70,60 @@ object Minecraft12111LoginBridge { 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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) @@ -81,6 +145,14 @@ object Minecraft12111LoginBridge { 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 = when (answer) { AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") AdmissionAnswer.CAPACITY -> Component.literal("This share is full") 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..0f6a77f31 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt @@ -0,0 +1,265 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.EditBox +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.network.chat.Component + +class ShareJoinScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.join.title")) { + private val browser = FabricShareBrowser() + private var scope: CoroutineScope? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var joinButton: Button? = null + private var invitationValue = "" + private var selectedLanAddress: String? = null + private var safeMessage: String? = null + private var discoveredFingerprint = 0 + private var joining = false + private var transferred = false + private var selectingDiscovered = false + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft!!.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.safeMessage } + } + discoveredFingerprint = browser.discovered.value.hashCode() + + addRenderableWidget(centered(title, 16)) + addRenderableWidget( + centered( + Component.translatable("connect_share.join.description"), + 34, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 52, + 310, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { value -> + invitationValue = value + if (!selectingDiscovered) { + selectedLanAddress = null + } + refresh() + } + }, + ) + + val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) + if (discovered.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.join.scanning"), + 88, + ), + ) + } else { + discovered.forEachIndexed { index, share -> + addRenderableWidget( + Button.builder(discoveredLabel(share)) { + selectDiscovered(share) + }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) + .build(), + ) + } + } + + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(width / 2 - 155, 134) + .selected(offlineMode?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ) + .build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(width / 2 - 155, 156) + .selected(internetDirect?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + .build(), + ) + + safeMessage?.let { + addRenderableWidget( + centered(Component.literal(it), 182).setMaxWidth(310), + ) + } + joinButton = addRenderableWidget( + Button.builder(Component.translatable("connect_share.join.join")) { + join() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + override fun tick() { + super.tick() + val next = browser.discovered.value.hashCode() + if (next != discoveredFingerprint) { + invitationValue = invitationBox?.value.orEmpty() + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + minecraft?.setScreen(parent) + } + + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } + + private fun selectDiscovered(share: DiscoveredLanShare) { + selectedLanAddress = share.lanAddress + invitationValue = share.invitationUri + selectingDiscovered = true + invitationBox?.value = invitationValue + selectingDiscovered = false + safeMessage = null + refresh() + } + + private fun join() { + if (joining || invitationValue.isBlank()) return + joining = true + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = selectedLanAddress, + internetOptIn = internetDirect?.selected() == true, + authMode = if (offlineMode?.selected() == true) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + }, + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = ::connect, + ) + } + } + + private fun connect(target: GuestJoinTarget) { + val client = minecraft ?: run { + target.close() + joining = false + return + } + 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, browser) + transferred = true + } else { + browser.close() + } + val data = ServerData( + "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + ConnectScreen.startConnecting(parent, client, address, data, false, null) + } + + private fun refresh() { + joinButton?.active = !joining && invitationValue.isNotBlank() + invitationBox?.setEditable(!joining) + } + + private fun discoveredLabel(share: DiscoveredLanShare): Component = + Component.translatable( + "connect_share.join.discovered", + share.displayName, + ) + + 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 companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_SHARES = 2 + } +} 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 index 475821537..d5eb538d6 100644 --- 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 @@ -3,8 +3,10 @@ package com.minekube.connect.share.fabric.v1_21_11 import com.minekube.connect.share.ShareGameMode import com.minekube.connect.share.fabric.ConnectShareClient 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.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 @@ -67,6 +69,24 @@ class ShareSetupScreen( Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(width / 2 - 155, 138) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), 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 index 78170c545..7f054729a 100644 --- 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 @@ -18,34 +18,68 @@ class ShareStatusScreen( override fun init() { val state = viewModel.state.value fingerprint = state.hashCode() - addRenderableWidget(centered(title, 18)) + addRenderableWidget(centered(title, 14)) val sharing = state.shareState as? ShareState.Sharing - val address = sharing?.address - ?: Component.translatable(statusKey(state.shareState)).string + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } addRenderableWidget( - centered( - Component.translatable("connect_share.status.address", address), - 38, - ), + centered(summary, 32), ) - val copy = addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.copy")) { + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft!!.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 48, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 - 50, 54, 100, 20).build(), + }.bounds(width / 2 + 5, 48, 150, 20).build(), ) - copy.active = sharing != null + copyAddress.active = sharing?.address != null + + sharing?.let { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.routes", + availability(it.connectAvailable), + availability(it.lanDirectAvailable), + availability(it.internetDirectAvailable), + ), + 76, + ).setMaxWidth(310), + ) + } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft?.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 80, 200, 20).build(), + }.bounds(width / 2 - 100, 92, 200, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + val visibleRows = ((height - 166) / 38).coerceIn(1, 3) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 108 + index * 38 + val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> @@ -87,14 +121,14 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 108 + visibleRows * 38, + 120 + visibleRows * 38, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.status.waiting"), - 116, + 128, ), ) } @@ -129,6 +163,9 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + private fun availability(available: Boolean): Component = + Component.translatable(if (available) "options.on" else "options.off") + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index e78f182c7..5cc09956c 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Mit Connect teilen", "connect_share.menu.active": "Connect Share aktiv", + "connect_share.menu.join": "Connect Share beitreten", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "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.start": "Teilen starten", "connect_share.game_mode.survival": "Überleben", "connect_share.game_mode.creative": "Kreativ", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Zuschauer", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.copy": "Adresse kopieren", + "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", + "connect_share.status.copy_invitation": "Einladung kopieren", + "connect_share.status.copy_address": "Vanilla-Adresse kopieren", + "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", "connect_share.status.idle": "Nicht geteilt", "connect_share.status.starting": "Wird gestartet…", "connect_share.status.active": "Aktiv", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s weitere Anfragen", "connect_share.status.waiting": "Warte auf Freunde…", "connect_share.status.stop": "Teilen 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.identity.manage": "Endpunkt-Identität…", "connect_share.identity.title": "Connect-Endpunkt-Identität", "connect_share.identity.current": "Endpunkt: %s", 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 index 1227e0ea9..b0a048bbb 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Share with Connect", "connect_share.menu.active": "Connect Share active", + "connect_share.menu.join": "Join Connect Share", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Invite friends without opening your world to the LAN.", "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow 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.start": "Start sharing", "connect_share.game_mode.survival": "Survival", "connect_share.game_mode.creative": "Creative", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Spectator", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Join address: %s", - "connect_share.status.copy": "Copy address", + "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", + "connect_share.status.copy_invitation": "Copy invitation", + "connect_share.status.copy_address": "Copy vanilla address", + "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s more requests", "connect_share.status.waiting": "Waiting for friends to join…", "connect_share.status.stop": "Stop sharing", + "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.identity.manage": "Endpoint identity…", "connect_share.identity.title": "Connect endpoint identity", "connect_share.identity.current": "Endpoint: %s", 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 index 1194ff0f5..3481a30ad 100644 --- 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 @@ -13,7 +13,8 @@ "IntegratedServerAccessor", "IntegratedServerMixin", "LanServerPingerAccessor", - "PauseScreenMixin" + "PauseScreenMixin", + "TitleScreenMixin" ], "injectors": { "defaultRequire": 1 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 index 4646ddbb1..ee9698dda 100644 --- 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 @@ -8,22 +8,22 @@ 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 exact vanilla initializer and group only for the armed thread`() { + 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()) - assertSame( - initializer, - CapturedServerTransport.captureChildInitializer(initializer), - ) + val taggedInitializer = + CapturedServerTransport.captureChildInitializer(initializer) + assertNotSame(initializer, taggedInitializer) assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) var otherThreadArmed = true @@ -33,7 +33,7 @@ class CapturedServerTransportTest { val captured = lease.complete().getOrNull() requireNotNull(captured) - assertSame(initializer, captured.childInitializer) + assertSame(taggedInitializer, captured.childInitializer) assertSame(group, captured.eventLoopGroup) assertFalse(otherThreadArmed) assertFalse(CapturedServerTransport.isShareStartArmed()) 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 index ba53c75b3..ff9741385 100644 --- 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 @@ -61,7 +61,8 @@ private void startClientVerification(GameProfile profile) { private void connectShare$awaitPassthroughAdmission( GameProfile profile, CallbackInfo callback) { - if (!Minecraft262LoginBridge.isPassthroughConnect(connection)) { + boolean direct = Minecraft262LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft262LoginBridge.isPassthroughConnect(connection)) { return; } if (connectShare$admissionAllowed) { @@ -73,11 +74,20 @@ private void startClientVerification(GameProfile profile) { return; } connectShare$admissionStarted = true; - Minecraft262LoginBridge.requestPassthroughAdmission( - connection, - server, - profile, - () -> connectShare$admissionAllowed = true, - this::disconnect); + 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..6b7d83b8c --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java @@ -0,0 +1,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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 index 687c08036..c39cf33f9 100644 --- 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 @@ -30,6 +30,10 @@ class ConnectShare262Client : ClientModInitializer { playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, + worldDisplayName = { + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world" + }, bridgeFactory = { admission, admissionScope -> Minecraft262Bridge { FabricLocalLoginAdmissionGate( @@ -50,6 +54,12 @@ class ConnectShare262Client : ClientModInitializer { ) } }, + guestScreens = { parent -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen(ShareJoinScreen(parentScreen)) + } + }, ) ConnectShareClient.install(installation) @@ -58,6 +68,9 @@ class ConnectShare262Client : ClientModInitializer { minecraft.hasSingleplayerServer(), minecraft.singleplayerServer, ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() 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 index d9acbe402..f3556f0ef 100644 --- 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 @@ -4,7 +4,13 @@ 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.DirectOnlineAuthenticationRequired +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +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 @@ -40,6 +46,10 @@ object Minecraft262LoginBridge { .map { it.player.auth.isPassthrough } .orElse(false) + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + @JvmStatic fun requestPassthroughAdmission( connection: Connection, @@ -60,6 +70,60 @@ object Minecraft262LoginBridge { 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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) @@ -81,6 +145,14 @@ object Minecraft262LoginBridge { 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 = when (answer) { AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") AdmissionAnswer.CAPACITY -> Component.literal("This share is full") 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..76fbafa2e --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt @@ -0,0 +1,260 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.EditBox +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.network.chat.Component + +class ShareJoinScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.join.title")) { + private val browser = FabricShareBrowser() + private var scope: CoroutineScope? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var joinButton: Button? = null + private var invitationValue = "" + private var selectedLanAddress: String? = null + private var safeMessage: String? = null + private var discoveredFingerprint = 0 + private var joining = false + private var transferred = false + private var selectingDiscovered = false + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.safeMessage } + } + discoveredFingerprint = browser.discovered.value.hashCode() + + addRenderableWidget(centered(title, 16)) + addRenderableWidget( + centered( + Component.translatable("connect_share.join.description"), + 34, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 52, + 310, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { value -> + invitationValue = value + if (!selectingDiscovered) { + selectedLanAddress = null + } + refresh() + } + }, + ) + + val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) + if (discovered.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.join.scanning"), + 88, + ), + ) + } else { + discovered.forEachIndexed { index, share -> + addRenderableWidget( + Button.builder(discoveredLabel(share)) { + selectDiscovered(share) + }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) + .build(), + ) + } + } + + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(width / 2 - 155, 134) + .selected(offlineMode?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ) + .build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(width / 2 - 155, 156) + .selected(internetDirect?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + .build(), + ) + + safeMessage?.let { + addRenderableWidget( + centered(Component.literal(it), 182).setMaxWidth(310), + ) + } + joinButton = addRenderableWidget( + Button.builder(Component.translatable("connect_share.join.join")) { + join() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + override fun tick() { + super.tick() + val next = browser.discovered.value.hashCode() + if (next != discoveredFingerprint) { + invitationValue = invitationBox?.value.orEmpty() + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } + + private fun selectDiscovered(share: DiscoveredLanShare) { + selectedLanAddress = share.lanAddress + invitationValue = share.invitationUri + selectingDiscovered = true + invitationBox?.value = invitationValue + selectingDiscovered = false + safeMessage = null + refresh() + } + + private fun join() { + if (joining || invitationValue.isBlank()) return + joining = true + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = selectedLanAddress, + internetOptIn = internetDirect?.selected() == true, + authMode = if (offlineMode?.selected() == true) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + }, + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = ::connect, + ) + } + } + + 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, browser) + transferred = true + } else { + browser.close() + } + val data = ServerData( + "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + ConnectScreen.startConnecting(parent, minecraft, address, data, false, null) + } + + private fun refresh() { + joinButton?.active = !joining && invitationValue.isNotBlank() + invitationBox?.setEditable(!joining) + } + + private fun discoveredLabel(share: DiscoveredLanShare): Component = + Component.translatable( + "connect_share.join.discovered", + share.displayName, + ) + + 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 companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_SHARES = 2 + } +} 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 index ac369e2fa..d0cb204d8 100644 --- 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 @@ -3,8 +3,10 @@ package com.minekube.connect.share.fabric.v26_2 import com.minekube.connect.share.ShareGameMode import com.minekube.connect.share.fabric.ConnectShareClient 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.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 @@ -67,6 +69,24 @@ class ShareSetupScreen( Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(width / 2 - 155, 138) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), 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 index 38c5c28f2..9e5f3ecaf 100644 --- 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 @@ -18,34 +18,68 @@ class ShareStatusScreen( override fun init() { val state = viewModel.state.value fingerprint = state.hashCode() - addRenderableWidget(centered(title, 18)) + addRenderableWidget(centered(title, 14)) val sharing = state.shareState as? ShareState.Sharing - val address = sharing?.address - ?: Component.translatable(statusKey(state.shareState)).string + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } addRenderableWidget( - centered( - Component.translatable("connect_share.status.address", address), - 38, - ), + centered(summary, 32), ) - val copy = addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.copy")) { + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 48, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 - 50, 54, 100, 20).build(), + }.bounds(width / 2 + 5, 48, 150, 20).build(), ) - copy.active = sharing != null + copyAddress.active = sharing?.address != null + + sharing?.let { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.routes", + availability(it.connectAvailable), + availability(it.lanDirectAvailable), + availability(it.internetDirectAvailable), + ), + 76, + ).setMaxWidth(310), + ) + } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 80, 200, 20).build(), + }.bounds(width / 2 - 100, 92, 200, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + val visibleRows = ((height - 166) / 38).coerceIn(1, 3) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 108 + index * 38 + val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> @@ -87,14 +121,14 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 108 + visibleRows * 38, + 120 + visibleRows * 38, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.status.waiting"), - 116, + 128, ), ) } @@ -129,6 +163,9 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + private fun availability(available: Boolean): Component = + Component.translatable(if (available) "options.on" else "options.off") + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index e78f182c7..5cc09956c 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Mit Connect teilen", "connect_share.menu.active": "Connect Share aktiv", + "connect_share.menu.join": "Connect Share beitreten", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "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.start": "Teilen starten", "connect_share.game_mode.survival": "Überleben", "connect_share.game_mode.creative": "Kreativ", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Zuschauer", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.copy": "Adresse kopieren", + "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", + "connect_share.status.copy_invitation": "Einladung kopieren", + "connect_share.status.copy_address": "Vanilla-Adresse kopieren", + "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", "connect_share.status.idle": "Nicht geteilt", "connect_share.status.starting": "Wird gestartet…", "connect_share.status.active": "Aktiv", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s weitere Anfragen", "connect_share.status.waiting": "Warte auf Freunde…", "connect_share.status.stop": "Teilen 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.identity.manage": "Endpunkt-Identität…", "connect_share.identity.title": "Connect-Endpunkt-Identität", "connect_share.identity.current": "Endpunkt: %s", 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 index 1227e0ea9..b0a048bbb 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Share with Connect", "connect_share.menu.active": "Connect Share active", + "connect_share.menu.join": "Join Connect Share", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Invite friends without opening your world to the LAN.", "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow 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.start": "Start sharing", "connect_share.game_mode.survival": "Survival", "connect_share.game_mode.creative": "Creative", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Spectator", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Join address: %s", - "connect_share.status.copy": "Copy address", + "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", + "connect_share.status.copy_invitation": "Copy invitation", + "connect_share.status.copy_address": "Copy vanilla address", + "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s more requests", "connect_share.status.waiting": "Waiting for friends to join…", "connect_share.status.stop": "Stop sharing", + "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.identity.manage": "Endpoint identity…", "connect_share.identity.title": "Connect endpoint identity", "connect_share.identity.current": "Endpoint: %s", 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 index 4087b3bcd..bd97d04d1 100644 --- 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 @@ -13,7 +13,8 @@ "IntegratedServerAccessor", "IntegratedServerMixin", "LanServerPingerAccessor", - "PauseScreenMixin" + "PauseScreenMixin", + "TitleScreenMixin" ], "injectors": { "defaultRequire": 1 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 index f4ef76355..e4e7a4ca2 100644 --- 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 @@ -7,15 +7,21 @@ fun interface ConnectShareScreenFactory { fun open(parent: Any, active: Boolean) } +fun interface ConnectShareGuestScreenFactory { + fun open(parent: Any) +} + data class ConnectShareInstallation( val viewModel: ShareViewModel, val runtime: ConnectShareRuntime, val screens: ConnectShareScreenFactory, + val guestScreens: ConnectShareGuestScreenFactory, ) object ConnectShareClient { @Volatile private var installation: ConnectShareInstallation? = null + private val guestLease = GuestConnectionLease() fun install(value: ConnectShareInstallation) { check(installation == null) { @@ -42,6 +48,23 @@ object ConnectShareClient { } } + @JvmStatic + fun openJoinScreen(parent: Any) { + installation?.guestScreens?.open(parent) + } + + fun holdGuestDirect( + target: GuestJoinTarget.Direct, + browser: FabricShareBrowser, + ) { + guestLease.hold(target, browser) + } + + @JvmStatic + fun guestConnectionChanged(connected: Boolean) { + guestLease.connectionChanged(connected) + } + @JvmStatic fun viewModel(): ShareViewModel = checkNotNull(installation).viewModel @@ -56,6 +79,7 @@ object ConnectShareClient { @JvmStatic fun shutdown() { + guestLease.close() installation?.runtime?.shutdown() } @@ -73,3 +97,64 @@ object ConnectShareClient { -> true } } + +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/FabricDirectAuthenticationPolicy.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt new file mode 100644 index 000000000..28284409c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt @@ -0,0 +1,25 @@ +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" +} + +object FabricDirectAuthenticationPolicy { + 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/FabricDirectShareIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt new file mode 100644 index 000000000..e5e7dc347 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt @@ -0,0 +1,207 @@ +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.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.security.SecureRandom +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +class FabricDirectShareIngress private constructor( + private val nodeFactory: () -> FabricDirectNode, + private val now: () -> Instant, + private val shareId: () -> UUID, + private val capability: () -> String, + private val displayName: () -> String, + private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, +) : DirectShareIngress { + constructor( + displayName: () -> String, + ) : this( + nodeFactory = { CoreFabricDirectNode(DirectP2pNode()) }, + now = Instant::now, + shareId = UUID::randomUUID, + capability = ::newCapability, + displayName = displayName, + localSocket = ::openTaggedLoopbackSocket, + ) + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + val node = nodeFactory() + try { + val id = shareId() + val secret = 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 payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = id, + expiresAtEpochMillis = now() + .plusSeconds(INVITATION_LIFETIME_SECONDS) + .toEpochMilli(), + connectAddress = connectAddress, + peerId = host.peerId(), + internetDirectEnabled = options.allowInternetDirect, + directCandidates = internetCandidates, + capability = secret, + ) + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + host.publicKey(), + ) + val invitation = ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = host.publicKey(), + signature = node.sign(unsigned), + ), + ) + node.publish(invitation) + val closed = AtomicBoolean() + return DirectShareHandle( + invitation = invitation, + lanAvailable = true, + internetAvailable = + options.allowInternetDirect && + internetCandidates.isNotEmpty(), + close = { + if (closed.compareAndSet(false, true)) { + node.close() + } + }, + ) + } catch (failure: Throwable) { + try { + node.close() + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } + throw failure + } + } + + companion object { + internal fun testing( + nodeFactory: () -> FabricDirectNode, + now: () -> Instant, + shareId: () -> UUID, + capability: () -> String, + displayName: () -> String, + localSocket: (SocketAddress, DirectP2pSession) -> Socket, + ) = FabricDirectShareIngress( + nodeFactory = nodeFactory, + now = now, + shareId = shareId, + capability = capability, + displayName = displayName, + localSocket = localSocket, + ) + + private fun newCapability(): String = ByteArray(CAPABILITY_BYTES) + .also(SecureRandom()::nextBytes) + .let(Base64.getUrlEncoder().withoutPadding()::encodeToString) + + 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 CAPABILITY_BYTES = 32 + private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L + 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) +} + +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) { + node.publish(invitation) + } + + 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 index 953258697..108ccf6c2 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -26,6 +27,7 @@ object FabricLoginAdmissionRegistry { uuid: UUID, connectionId: String, minecraftAuthenticated: Boolean, + ingress: Ingress, ): CompletionStage { val gate = installed.get() if (gate == null) { @@ -36,6 +38,7 @@ object FabricLoginAdmissionRegistry { uuid = uuid, connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, + ingress = ingress, ) } } 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 index 68f9a6b4f..264f76645 100644 --- 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 @@ -132,6 +132,7 @@ class FabricLocalLoginAdmission( uuid: UUID, connectionId: String, minecraftAuthenticated: Boolean, + ingress: Ingress = Ingress.CONNECT, ): AdmissionAnswer { val identity = if (minecraftAuthenticated) { AdmissionIdentity.Authenticated( @@ -144,7 +145,7 @@ class FabricLocalLoginAdmission( name = name, uuid = uuid, connectionId = connectionId, - ingress = Ingress.CONNECT, + ingress = ingress, ) } return admission.request(identity) @@ -163,6 +164,7 @@ class FabricLocalLoginAdmissionGate( uuid: UUID, connectionId: String, minecraftAuthenticated: Boolean, + ingress: Ingress = Ingress.CONNECT, ): CompletionStage { val future = CompletableFuture() if (stopped.get()) { @@ -179,6 +181,7 @@ class FabricLocalLoginAdmissionGate( uuid = uuid, connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, + ingress = ingress, ), ) } catch (cancellation: CancellationException) { 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 index 0d8e80c7c..43eb31ec5 100644 --- 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 @@ -26,9 +26,11 @@ object FabricShareBootstrap { minecraftVersion: String, worldAvailable: Boolean, playerCount: () -> Int, + worldDisplayName: () -> String = { "Minecraft world" }, bridgeFactory: (AdmissionController, CoroutineScope) -> VersionedMinecraftBridge, screens: ConnectShareScreenFactory, + guestScreens: ConnectShareGuestScreenFactory, environment: Map = System.getenv(), logger: ConnectLogger = FabricConnectLogger(), httpClient: OkHttpClient = OkHttpClient(), @@ -67,11 +69,15 @@ object FabricShareBootstrap { admission = admission, scope = scope, ) + val directIngress = FabricDirectShareIngress( + displayName = worldDisplayName, + ) val coordinator = ShareCoordinator( bridge = bridge, ingress = ingress, identityProvider = identityStore::currentOrCreate, admission = admission, + directIngress = directIngress, failureReporter = logger::warn, ) val viewModel = ShareViewModel( @@ -99,6 +105,7 @@ object FabricShareBootstrap { viewModel = viewModel, runtime = runtime, screens = screens, + guestScreens = guestScreens, ) } 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..de6f9f0da --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt @@ -0,0 +1,280 @@ +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.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.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +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 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 + } +} + +class FabricShareBrowser private constructor( + private val node: FabricGuestDirectNode, + private val now: () -> Instant, + private val ioDispatcher: CoroutineDispatcher, +) : AutoCloseable { + constructor() : this( + node = CoreFabricGuestDirectNode(DirectP2pNode()), + now = Instant::now, + ioDispatcher = Dispatchers.IO, + ) + + private val mutableDiscovered = + MutableStateFlow>(emptyList()) + private val started = AtomicBoolean() + private val closed = AtomicBoolean() + + val discovered: StateFlow> = + mutableDiscovered.asStateFlow() + + 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 routes = TransportSelector.plan( + sameLan = lanAddress != null, + hostInternetOptIn = payload.internetDirectEnabled, + guestInternetOptIn = internetOptIn, + connectAddress = payload.connectAddress, + ) + return withContext(ioDispatcher) { + for (route in routes.distinct()) { + when (route) { + ShareRoute.DIRECT_LAN -> { + val address = lanAddress ?: continue + openDirect( + route, + address, + invitation, + authMode, + LAN_TIMEOUT, + )?.let { return@withContext it.right() } + } + + ShareRoute.DIRECT_INTERNET -> { + for (address in payload.directCandidates) { + openDirect( + route, + address, + invitation, + authMode, + INTERNET_TIMEOUT, + )?.let { return@withContext it.right() } + } + } + + ShareRoute.CONNECT -> { + payload.connectAddress?.let { + return@withContext GuestJoinTarget.Connect(it).right() + } + } + } + } + GuestJoinFailure.NoRoute.left() + } + } + + 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.value = ( + mutableDiscovered.value.filterNot { + it.invitation.payload.shareId == invitation.payload.shareId + } + found + ).takeLast(MAX_DISCOVERED_SHARES) + } + + private fun openDirect( + route: ShareRoute, + address: String, + invitation: SignedShareInvite, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): GuestJoinTarget.Direct? = try { + val payload = invitation.payload + val proxy = node.openProxy( + address = address, + shareId = payload.shareId.toString(), + capability = payload.capability, + authMode = authMode, + timeout = timeout, + ) + GuestJoinTarget.Direct( + route = route, + localAddress = proxy.localAddress(), + proxy = proxy, + ) + } catch (_: RuntimeException) { + null + } + + companion object { + internal fun testing( + node: FabricGuestDirectNode, + now: () -> Instant, + ioDispatcher: CoroutineDispatcher, + ) = FabricShareBrowser(node, now, ioDispatcher) + + private val LAN_TIMEOUT = Duration.ofSeconds(3) + private val INTERNET_TIMEOUT = Duration.ofSeconds(5) + private const val MAX_DISCOVERED_SHARES = 32 + } +} + +internal interface FabricGuestDirectNode : AutoCloseable { + 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 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/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt index c25fb9128..627c8a39a 100644 --- 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 @@ -175,6 +175,12 @@ class ShareViewModel( } } + fun setAllowInternetDirect(allowed: Boolean) { + update { + copy(options = options.copy(allowInternetDirect = allowed)) + } + } + fun start() { if (!state.value.startEnabled) return scope.launch(start = CoroutineStart.UNDISPATCHED) { 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..809a8046b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt @@ -0,0 +1,28 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlin.test.Test +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, + ), + ) + } +} 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..8bb182467 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt @@ -0,0 +1,178 @@ +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 java.net.InetSocketAddress +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.runTest + +class FabricDirectShareIngressTest { + @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) + 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 `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) + } + + 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 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) { + if (failPublish) { + error("publish failed") + } + published = 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 index 4bb5ce895..418da7aac 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -27,12 +28,14 @@ class FabricLocalLoginAdmissionGateTest { 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() 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..288c0a862 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt @@ -0,0 +1,184 @@ +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.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.security.KeyPairGenerator +import java.security.Signature +import java.time.Duration +import java.time.Instant +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 + +class FabricShareBrowserTest { + @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 `failed direct reachability falls back to Connect exactly once`() = + runTest { + val node = FakeGuestNode(failDirect = true) + val browser = browser(node) + + 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, + ) + 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) = + FabricShareBrowser.testing( + node = node, + now = { Instant.ofEpochMilli(NOW) }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + private fun invitation(): String { + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = SHARE_ID, + expiresAtEpochMillis = NOW + 60_000, + connectAddress = "amber-fox.play.minekube.net", + peerId = PEER_ID, + internetDirectEnabled = true, + directCandidates = listOf(INTERNET_ADDRESS), + 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 class FakeGuestNode( + private val failDirect: Boolean = false, + ) : FabricGuestDirectNode { + private var listener: DirectP2pDiscoveryListener? = null + val openedAddresses = mutableListOf() + + 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: 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/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 } +} From a5fe9e60bb2afed43fd1fa107295737db377a8b7 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:15:12 +0200 Subject: [PATCH 020/188] feat: show Connect Share admission routes --- .../share/admission/AdmissionIdentity.kt | 1 + .../fabric/v1_21_11/ShareStatusScreen.kt | 19 +++++++++++---- .../v1_21_11/Fabric12111ArtifactTest.kt | 6 +++++ .../share/fabric/v26_2/ShareStatusScreen.kt | 19 +++++++++++---- .../fabric/v26_2/Fabric262ArtifactTest.kt | 6 +++++ .../fabric/FabricSessionAdmissionGate.kt | 1 + .../FabricLocalLoginAdmissionGateTest.kt | 24 +++++++++++++++++++ 7 files changed, 68 insertions(+), 8 deletions(-) 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 index 6b07dc41e..6834b92dd 100644 --- 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 @@ -10,6 +10,7 @@ sealed interface AdmissionIdentity { override val name: String, override val uuid: UUID, val source: AuthSource, + val ingress: Ingress = Ingress.CONNECT, ) : AdmissionIdentity data class UnverifiedOffline( 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 index 7f054729a..15335c29a 100644 --- 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 @@ -2,6 +2,7 @@ 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.Ingress import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -82,10 +83,14 @@ class ShareStatusScreen( val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { - is AdmissionIdentity.Authenticated -> - identity.source.name.lowercase() - - is AdmissionIdentity.UnverifiedOffline -> "offline" + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( "connect_share.status.request", @@ -166,6 +171,12 @@ class ShareStatusScreen( private fun availability(available: Boolean): Component = Component.translatable(if (available) "options.on" else "options.off") + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index b1106c6cb..f44289d7b 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -42,6 +43,10 @@ class Fabric12111ArtifactTest { 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, + ) } } @@ -79,6 +84,7 @@ class Fabric12111ArtifactTest { listOf( ConnectShareClient::class.java, ShareCoordinator::class.java, + DirectP2pNode::class.java, Libp2pEndpoint::class.java, Libp2pTunnelTransport::class.java, ).forEach(::assertParentFacingTypes) 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 index 9e5f3ecaf..98c096891 100644 --- 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 @@ -2,6 +2,7 @@ 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.Ingress import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -82,10 +83,14 @@ class ShareStatusScreen( val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { - is AdmissionIdentity.Authenticated -> - identity.source.name.lowercase() - - is AdmissionIdentity.UnverifiedOffline -> "offline" + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( "connect_share.status.request", @@ -166,6 +171,12 @@ class ShareStatusScreen( private fun availability(available: Boolean): Component = Component.translatable(if (available) "options.on" else "options.off") + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index 2532610aa..ffaee500f 100644 --- 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 @@ -2,6 +2,7 @@ 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.nio.file.Files @@ -42,6 +43,10 @@ class Fabric262ArtifactTest { 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, + ) } } @@ -79,6 +84,7 @@ class Fabric262ArtifactTest { listOf( ConnectShareClient::class.java, ShareCoordinator::class.java, + DirectP2pNode::class.java, Libp2pEndpoint::class.java, Libp2pTunnelTransport::class.java, ).forEach(::assertParentFacingTypes) 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 index 264f76645..ca50fbd30 100644 --- 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 @@ -139,6 +139,7 @@ class FabricLocalLoginAdmission( name = name, uuid = uuid, source = AuthSource.MOJANG, + ingress = ingress, ) } else { AdmissionIdentity.UnverifiedOffline( 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 index 418da7aac..1762dd2be 100644 --- 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 @@ -15,6 +15,30 @@ 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() From 028bf246c3437dfce28a3529586d072b0197f1ba Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:15:12 +0200 Subject: [PATCH 021/188] docs: document direct share acceptance --- README.md | 16 ++++-- docs/connect-share-testing.md | 57 ++++++++++++++++++- .../2026-07-30-connect-share-direct-p2p.md | 2 +- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 832e00041..3f76fc468 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,30 @@ Please refer to https://connect.minekube.com for more documentation. ## Connect Share Fabric mod Connect Share is an in-development client-side Fabric mod for Minecraft Java -1.21.11 and 26.2. It shares a singleplayer world through the normal Connect -network without exposing Minecraft's LAN listener to the local network. +1.21.11 and 26.2. It shares a singleplayer world through Minekube Connect or +directly between two modded clients without exposing Minecraft's listener to +the LAN or internet. -The first slice provides: +The current implementation provides: - a native **Share with Connect** flow in the pause menu; +- a native **Join Connect Share** flow on the title screen; - one persistent endpoint identity reused across worlds and restarts; - import of an existing dashboard endpoint and token, including `token.json`; - `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; - a stable `*.play.minekube.net` address for unmodified Java clients; +- signed, temporary invitations for modded clients; +- automatic same-LAN discovery and direct libp2p transport; +- optional internet-direct attempts only when host and guest both opt in; +- exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; -- support for both authenticated and offline-mode guests; and +- explicit support for authenticated and unverified offline-mode guests; and - isolated, self-contained Fabric artifacts for both supported game versions. The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See [docs/connect-share-testing.md](docs/connect-share-testing.md) for the manual -singleplayer acceptance pass. +singleplayer, direct-connect, and fallback acceptance pass. ## Integrating with login / auth plugins diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 176b54304..26f2cfd9b 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,8 +1,8 @@ -# Connect Share singleplayer acceptance +# Connect Share acceptance Connect Share is built separately for Minecraft Java 1.21.11 on Java 21 and Minecraft Java 26.2 on Java 25. Run this pass against both artifacts before -calling the singleplayer slice release-ready. +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. @@ -56,6 +56,54 @@ For each supported host version: 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 with Connect**, and leave + **Allow direct internet connections** disabled. +2. On the guest title screen, choose **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. +8. Start sharing again. Confirm the libp2p peer identity, share capability, and + invitation changed while the persistent Connect endpoint did not. + +## Invitation, internet-direct, and fallback behavior + +Internet-direct is best-effort and requires an actually reachable public +address, such as a publicly routed host or an explicitly configured network. +The mod does not open a public Minecraft listener, configure UPnP, or use a +self-hosted libp2p relay. + +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 internet-direct disabled on either peer, confirm the guest does not + attempt a direct internet route and uses Connect once. +3. Enable internet-direct on both peers. Confirm both UIs disclose that the + path reveals public IP addresses before it is attempted. +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 one bounded direct attempt is 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. + ## Listener and lifecycle safety 1. While sharing, scan the host from another LAN device. Confirm Minecraft's @@ -72,6 +120,9 @@ For each supported host version: 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 @@ -93,6 +144,8 @@ Each final artifact must contain: 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`. ## Evidence to retain 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 index 41b5f68cd..c3fc94ef6 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -42,7 +42,7 @@ classloader boundary. 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/QUIC, mDNS, +- 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 From 23d4ad48c0866a7736ee23cc5e0f62bc3f2bd492 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:20:22 +0200 Subject: [PATCH 022/188] fix: package direct runtime in isolated payload --- .../tunnel/p2p/Libp2pRuntimeLoader.java | 2 +- share/fabric-1.21.11/build.gradle.kts | 11 ++++++++++- .../v1_21_11/Fabric12111ArtifactTest.kt | 19 +++++++++++++++++++ share/fabric-26.2/build.gradle.kts | 11 ++++++++++- .../fabric/v26_2/Fabric262ArtifactTest.kt | 19 +++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) 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 48d20db07..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 @@ -127,8 +127,8 @@ private static RuntimeLocation runtimeLocation() { try (InputStream input = packaged) { Path payload = extractRuntimePayload(input); Set urls = new LinkedHashSet<>(); - codeSourceUrl().ifPresent(urls::add); urls.add(payload.toUri().toURL()); + codeSourceUrl().ifPresent(urls::add); return new RuntimeLocation(urls.toArray(new URL[0]), payload); } catch (IOException e) { throw new IllegalStateException( diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 832ce7ba2..5025797dd 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -96,7 +96,16 @@ relocate("org.bstats") relocate("org.geysermc.configutils") relocate("org.yaml.snakeyaml") -val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") +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-fabric-1.21.11") 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 index f44289d7b..e965a73d2 100644 --- 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 @@ -70,7 +70,26 @@ class Fabric12111ArtifactTest { 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 } diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 92915b131..337552ca6 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -90,7 +90,16 @@ relocate("org.bstats") relocate("org.geysermc.configutils") relocate("org.yaml.snakeyaml") -val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") +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-fabric-26.2") 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 index ffaee500f..8f01ce6be 100644 --- 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 @@ -70,7 +70,26 @@ class Fabric262ArtifactTest { 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 } From 9f7c5dd624b9685663d0e0fb668078e49714178a Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:23:13 +0200 Subject: [PATCH 023/188] chore: ignore Fabric run state --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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/ From 865a3f0a2fe38703b5ac9d0d0e8fd8f5aeaf2259 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:57:22 +0200 Subject: [PATCH 024/188] fix(share): isolate fastutil from Minecraft --- share/fabric-1.21.11/build.gradle.kts | 2 +- .../share/fabric/v1_21_11/Fabric12111ArtifactTest.kt | 8 ++++++++ share/fabric-26.2/build.gradle.kts | 2 +- .../connect/share/fabric/v26_2/Fabric262ArtifactTest.kt | 8 ++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 5025797dd..cf140f8e3 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -85,7 +85,7 @@ relocate("com.google.common") relocate("com.google.gson") relocate("com.google.inject") relocate("com.google.protobuf") -relocate("com.nukkitx.fastutil") +relocate("it.unimi.dsi.fastutil") relocate("io.grpc") relocate("io.leangen.geantyref") relocate("jakarta.inject") 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 index e965a73d2..064ebdf1e 100644 --- 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 @@ -34,7 +34,15 @@ class Fabric12111ArtifactTest { 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 -> diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 337552ca6..2957ca203 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -79,7 +79,7 @@ relocate("com.google.common") relocate("com.google.gson") relocate("com.google.inject") relocate("com.google.protobuf") -relocate("com.nukkitx.fastutil") +relocate("it.unimi.dsi.fastutil") relocate("io.grpc") relocate("io.leangen.geantyref") relocate("jakarta.inject") 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 index 8f01ce6be..6ec11ee65 100644 --- 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 @@ -34,7 +34,15 @@ class Fabric262ArtifactTest { 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 -> From d9b60e856244d20ac90c2cb318668c2ae8fc6f65 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 22:32:54 +0200 Subject: [PATCH 025/188] fix(share): preserve Mojang Guava ABI --- share/fabric-1.21.11/build.gradle.kts | 8 +++++++ .../v1_21_11/MinecraftGameProfileFactory.java | 21 ++++++++++++++++++ .../v1_21_11/ConnectGameProfileMapper.kt | 12 ++++------ .../v1_21_11/Fabric12111ArtifactTest.kt | 22 +++++++++++++++++++ share/fabric-26.2/build.gradle.kts | 8 +++++++ .../v26_2/MinecraftGameProfileFactory.java | 21 ++++++++++++++++++ .../fabric/v26_2/ConnectGameProfileMapper.kt | 12 ++++------ .../fabric/v26_2/Fabric262ArtifactTest.kt | 22 +++++++++++++++++++ 8 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/MinecraftGameProfileFactory.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/MinecraftGameProfileFactory.java diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index cf140f8e3..b3bfb8feb 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -106,6 +106,9 @@ val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { ) } } +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") @@ -113,6 +116,8 @@ val connectShareShadowJar = tasks.named("shadowJar") { 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) @@ -126,6 +131,9 @@ val connectShareJar = tasks.register("connectShareJar") { from(libp2pRuntimeJar) { into("META-INF/connect") } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } } tasks.remapJar { 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/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 index f67e08a4b..0787ece34 100644 --- 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 @@ -3,10 +3,8 @@ 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.google.common.collect.ArrayListMultimap import com.mojang.authlib.GameProfile import com.mojang.authlib.properties.Property -import com.mojang.authlib.properties.PropertyMap import com.minekube.connect.api.player.GameProfile as ConnectGameProfile import net.minecraft.util.StringUtil @@ -20,23 +18,21 @@ object ConnectGameProfileMapper { ) { ProfileMappingFailure.InvalidName } - val properties = ArrayListMultimap.create() - source.properties.forEach { property -> + val properties = source.properties.map { property -> ensure(property.name.isNotBlank() && property.value.isNotBlank()) { ProfileMappingFailure.InvalidProperty } val signature = property.signature?.takeIf(String::isNotEmpty) - val mapped = if (signature == null) { + if (signature == null) { Property(property.name, property.value) } else { Property(property.name, property.value, signature) } - properties.put(property.name, mapped) } - GameProfile( + MinecraftGameProfileFactory.create( source.uniqueId, source.username, - PropertyMap(properties), + properties, ) } 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 index 064ebdf1e..4dcbfedd2 100644 --- 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 @@ -58,6 +58,28 @@ class Fabric12111ArtifactTest { } } + @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( diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 2957ca203..c687ff810 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -100,6 +100,9 @@ val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { ) } } +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") @@ -107,6 +110,8 @@ val connectShareShadowJar = tasks.named("shadowJar") { 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) @@ -120,6 +125,9 @@ val connectShareJar = tasks.register("connectShareJar") { from(libp2pRuntimeJar) { into("META-INF/connect") } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } } tasks.assemble { 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/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 index b56fd12c2..1c23b4404 100644 --- 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 @@ -3,10 +3,8 @@ package com.minekube.connect.share.fabric.v26_2 import arrow.core.Either import arrow.core.raise.either import arrow.core.raise.ensure -import com.google.common.collect.ArrayListMultimap import com.mojang.authlib.GameProfile import com.mojang.authlib.properties.Property -import com.mojang.authlib.properties.PropertyMap import com.minekube.connect.api.player.GameProfile as ConnectGameProfile import net.minecraft.util.StringUtil @@ -20,23 +18,21 @@ object ConnectGameProfileMapper { ) { ProfileMappingFailure.InvalidName } - val properties = ArrayListMultimap.create() - source.properties.forEach { property -> + val properties = source.properties.map { property -> ensure(property.name.isNotBlank() && property.value.isNotBlank()) { ProfileMappingFailure.InvalidProperty } val signature = property.signature?.takeIf(String::isNotEmpty) - val mapped = if (signature == null) { + if (signature == null) { Property(property.name, property.value) } else { Property(property.name, property.value, signature) } - properties.put(property.name, mapped) } - GameProfile( + MinecraftGameProfileFactory.create( source.uniqueId, source.username, - PropertyMap(properties), + properties, ) } 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 index 6ec11ee65..5f0854e16 100644 --- 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 @@ -58,6 +58,28 @@ class Fabric262ArtifactTest { } } + @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 `packaged loader reads libp2p only from child payload`() { URLClassLoader( From f3a98cebcd202bedeac98c5a9bca806579bb7455 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 23:09:00 +0200 Subject: [PATCH 026/188] docs(share): design pasted LAN invitation matching --- ...-connect-share-pasted-lan-invite-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md 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..c23989fd9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md @@ -0,0 +1,101 @@ +# Connect Share Pasted LAN Invitation Design + +**Date:** 2026-07-30 +**Status:** Approved for implementation +**Parent design:** `2026-07-30-connect-share-mod-design.md` + +## 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. From 339afae5163036acacad25e0a3fa1607fb7c2946 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 23:11:38 +0200 Subject: [PATCH 027/188] docs(share): plan pasted LAN invitation fix --- ...6-07-30-connect-share-pasted-lan-invite.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-connect-share-pasted-lan-invite.md 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. From bf122132131b37e427a64f2596979b1901aaee87 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 23:14:01 +0200 Subject: [PATCH 028/188] fix(share): prefer discovered LAN route for pasted invites --- .../share/fabric/FabricShareBrowser.kt | 17 ++- .../share/fabric/FabricShareBrowserTest.kt | 102 +++++++++++++++++- 2 files changed, 113 insertions(+), 6 deletions(-) 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 index de6f9f0da..96c3c7405 100644 --- 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 @@ -133,8 +133,10 @@ class FabricShareBrowser private constructor( ifRight = { it }, ) val payload = invitation.payload + val effectiveLanAddress = + lanAddress ?: matchingLanAddress(invitation) val routes = TransportSelector.plan( - sameLan = lanAddress != null, + sameLan = effectiveLanAddress != null, hostInternetOptIn = payload.internetDirectEnabled, guestInternetOptIn = internetOptIn, connectAddress = payload.connectAddress, @@ -143,7 +145,7 @@ class FabricShareBrowser private constructor( for (route in routes.distinct()) { when (route) { ShareRoute.DIRECT_LAN -> { - val address = lanAddress ?: continue + val address = effectiveLanAddress ?: continue openDirect( route, address, @@ -204,6 +206,17 @@ class FabricShareBrowser private constructor( ).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 openDirect( route: ShareRoute, address: String, 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 index 288c0a862..7905adc98 100644 --- 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 @@ -67,6 +67,91 @@ class FabricShareBrowserTest { 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 `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 { @@ -114,16 +199,19 @@ class FabricShareBrowserTest { ioDispatcher = StandardTestDispatcher(testScheduler), ) - private fun invitation(): String { + 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 = SHARE_ID, + shareId = shareId, expiresAtEpochMillis = NOW + 60_000, connectAddress = "amber-fox.play.minekube.net", - peerId = PEER_ID, + peerId = peerId, internetDirectEnabled = true, - directCandidates = listOf(INTERNET_ADDRESS), + directCandidates = listOf(internetAddress(peerId)), capability = CAPABILITY, ) val unsigned = ShareInviteCodec.unsignedBytes(payload, pair.public.encoded) @@ -137,6 +225,12 @@ class FabricShareBrowserTest { ) } + 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 { From 0e560ef212433b6d35e2653cb8ede51a415b9dcf Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 00:23:01 +0200 Subject: [PATCH 029/188] feat(share): persist direct peer identity across worlds --- .../connect/tunnel/p2p/DirectP2pNode.java | 18 ++- .../tunnel/p2p/DirectP2pNodeRuntime.java | 9 +- .../tunnel/p2p/MdnsAddressSelector.java | 150 ++++++++++++++++++ .../connect/tunnel/p2p/DirectP2pNodeTest.java | 34 ++++ .../tunnel/p2p/MdnsAddressSelectorTest.java | 81 ++++++++++ .../share/fabric/FabricDirectShareIngress.kt | 9 +- .../share/fabric/FabricShareBootstrap.kt | 1 + .../fabric/FabricDirectShareIngressTest.kt | 37 +++++ 8 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelector.java create mode 100644 core/src/test/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelectorTest.java 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 index d83017cd3..838a9cc11 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -24,6 +24,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Path; import java.time.Duration; import java.util.Objects; @@ -41,15 +42,26 @@ public final class DirectP2pNode implements AutoCloseable { 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 = - runtimeClass.getDeclaredConstructor(); + java.lang.reflect.Constructor constructor = identityFile == null + ? runtimeClass.getDeclaredConstructor() + : runtimeClass.getDeclaredConstructor(Path.class); constructor.setAccessible(true); - runtime = constructor.newInstance(); + runtime = identityFile == null + ? constructor.newInstance() + : constructor.newInstance(identityFile); startHost = accessible(runtimeClass.getDeclaredMethod( "startHost", DirectP2pHostConfig.class, 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 index 07488f856..fff9cf5b9 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -57,6 +57,7 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; +import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -111,6 +112,12 @@ final class DirectP2pNodeRuntime { this.privateKey = pair.getFirst(); } + DirectP2pNodeRuntime(Path identityFile) throws IOException { + this.privateKey = EndpointPeerIdentity + .loadOrCreate(Objects.requireNonNull(identityFile, "identityFile")) + .privateKey(); + } + synchronized DirectP2pHostInfo startHost( DirectP2pHostConfig config, DirectP2pHostHandler handler) { @@ -301,7 +308,7 @@ private synchronized void startMdns() { host, MDNS_SERVICE, MDNS_QUERY_INTERVAL_SECONDS, - null); + MdnsAddressSelector.systemAddress()); discovery.addHandler(peer -> { onMdnsPeer(peer); return Unit.INSTANCE; 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/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java index db16660b8..2f0da1ff6 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -45,8 +45,12 @@ 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; @@ -142,6 +146,36 @@ void everyHostUsesAnEphemeralPeerIdentityAndSignsWithIt() throws Exception { 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 publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { host = new DirectP2pNode(); 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/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 index e5e7dc347..cbb63d97e 100644 --- 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 @@ -16,6 +16,7 @@ import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress +import java.nio.file.Path import java.security.SecureRandom import java.time.Instant import java.util.Base64 @@ -31,9 +32,14 @@ class FabricDirectShareIngress private constructor( private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, ) : DirectShareIngress { constructor( + dataDirectory: Path, displayName: () -> String, ) : this( - nodeFactory = { CoreFabricDirectNode(DirectP2pNode()) }, + nodeFactory = { + CoreFabricDirectNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ) + }, now = Instant::now, shareId = UUID::randomUUID, capability = ::newCapability, @@ -170,6 +176,7 @@ class FabricDirectShareIngress private constructor( } private const val DEFAULT_DISPLAY_NAME = "Minecraft world" + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" private const val CAPABILITY_BYTES = 32 private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L private const val LOCAL_CONNECT_TIMEOUT_MILLIS = 3_000 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 index 43eb31ec5..0b6c727b0 100644 --- 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 @@ -70,6 +70,7 @@ object FabricShareBootstrap { scope = scope, ) val directIngress = FabricDirectShareIngress( + dataDirectory = dataDirectory, displayName = worldDisplayName, ) val coordinator = ShareCoordinator( 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 index 8bb182467..2904d3820 100644 --- 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 @@ -8,7 +8,9 @@ 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 @@ -19,8 +21,12 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir class FabricDirectShareIngressTest { + @TempDir + lateinit var tempDir: Path + @Test fun `publishes a signed invitation with Connect fallback and opted-in candidates`() = runTest { @@ -123,6 +129,37 @@ class FabricDirectShareIngressTest { 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 firstPeerId = assertIs>( + ShareInviteCodec.decode(first.invitation), + ).value.payload.peerId + first.close() + Libp2pRuntime.close() + + val secondIngress = FabricDirectShareIngress( + dataDirectory = tempDir, + displayName = { "Second World" }, + ) + val second = secondIngress.start(OPTIONS, target, null) + val secondPeerId = assertIs>( + ShareInviteCodec.decode(second.invitation), + ).value.payload.peerId + + assertEquals(firstPeerId, secondPeerId) + second.close() + Libp2pRuntime.close() + } + private class FakeDirectNode( private val failPublish: Boolean = false, ) : FabricDirectNode { From 669e996c9108de1a906226744dcee221770f47bf Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 00:35:28 +0200 Subject: [PATCH 030/188] feat(share): persist friend access across worlds --- .../connect/share/friend/FriendStore.kt | 337 ++++++++++++++++++ .../share/friend/ShareAccessIdentityStore.kt | 177 +++++++++ .../share/friend/SharePreferencesStore.kt | 101 ++++++ .../connect/share/friend/FriendStoreTest.kt | 141 ++++++++ .../friend/ShareAccessIdentityStoreTest.kt | 89 +++++ .../share/friend/SharePreferencesStoreTest.kt | 25 ++ .../share/fabric/ConnectShareRuntime.kt | 45 ++- .../share/fabric/FabricDirectShareIngress.kt | 30 +- .../share/fabric/FabricShareBootstrap.kt | 17 + .../connect/share/fabric/ui/ShareViewModel.kt | 66 +++- .../share/fabric/ConnectShareRuntimeTest.kt | 26 ++ .../fabric/FabricDirectShareIngressTest.kt | 15 +- .../share/fabric/ui/ShareViewModelTest.kt | 66 +++- 13 files changed, 1082 insertions(+), 53 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStore.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStoreTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt 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..b38168072 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt @@ -0,0 +1,337 @@ +package com.minekube.connect.share.friend + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +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 + +data class FriendPermissions( + val notifyWhenOnline: Boolean = true, + val canSeeMyWorlds: Boolean = true, + val canJoinAutomatically: Boolean = false, +) + +data class SavedFriend( + val peerId: String, + val publicKeyBase64: String, + val shareId: UUID, + val capability: String, + val connectAddress: String?, + val displayName: String, + val permissions: FriendPermissions = FriendPermissions(), +) { + override fun toString(): String = + "SavedFriend(peerId=$peerId, publicKey=, " + + "shareId=$shareId, capability=, " + + "connectAddress=$connectAddress, displayName=$displayName, " + + "permissions=$permissions)" +} + +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" + } +} + +class FriendStore( + private val directory: Path, +) { + @Synchronized + fun all(): List = read() + + @Synchronized + fun accept( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): 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) + val existing = current.firstOrNull { + it.peerId == invite.payload.peerId + } + ensure(existing == null || existing.publicKeyBase64 == publicKey) { + FriendStoreError.IdentityConflict + } + val friend = SavedFriend( + peerId = invite.payload.peerId, + publicKeyBase64 = publicKey, + shareId = invite.payload.shareId, + capability = invite.payload.capability, + connectAddress = invite.payload.connectAddress, + displayName = existing?.displayName ?: normalizedName, + permissions = existing?.permissions ?: FriendPermissions(), + ) + write( + current.filterNot { it.peerId == friend.peerId } + friend, + ) + 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 remove(peerId: String): Boolean { + val current = read() + val remaining = current.filterNot { it.peerId == peerId } + if (remaining.size == current.size) { + return false + } + write(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 { + Files.createDirectories(directory) + if (!Files.exists(friendsFile)) { + return emptyList() + } + try { + val root = GSON.fromJson( + Files.readString(friendsFile), + JsonObject::class.java, + ) ?: throw IOException("Friends file is empty") + if (root.requiredInt("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) + } + 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") + } + return friends + } 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 parseFriend(json: JsonObject): SavedFriend { + val peerId = json.requiredString("peerId") + val publicKey = json.requiredString("publicKey") + val shareId = UUID.fromString(json.requiredString("shareId")) + val capability = json.requiredString("capability") + val connectAddress = json.optionalString("connectAddress") + val displayName = json.requiredString("displayName") + if ( + peerId.isBlank() || + publicKey.isBlank() || + !isValidCapability(capability) || + 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") + return SavedFriend( + peerId = peerId, + publicKeyBase64 = publicKey, + shareId = shareId, + capability = capability, + connectAddress = connectAddress, + displayName = displayName, + permissions = FriendPermissions( + notifyWhenOnline = permissions.requiredBoolean("notifyWhenOnline"), + canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), + canJoinAutomatically = + permissions.requiredBoolean("canJoinAutomatically"), + ), + ) + } + + private fun write(friends: List) { + require(friends.size <= MAX_FRIENDS) { + "Connect Share supports at most $MAX_FRIENDS saved friends" + } + Files.createDirectories(directory) + val entries = JsonArray() + friends.sortedBy { it.displayName.lowercase() }.forEach { friend -> + entries.add(JsonObject().apply { + addProperty("peerId", friend.peerId) + addProperty("publicKey", friend.publicKeyBase64) + addProperty("shareId", friend.shareId.toString()) + addProperty("capability", friend.capability) + friend.connectAddress?.let { + addProperty("connectAddress", it) + } + addProperty("displayName", friend.displayName) + add( + "permissions", + JsonObject().apply { + addProperty( + "notifyWhenOnline", + friend.permissions.notifyWhenOnline, + ) + addProperty( + "canSeeMyWorlds", + friend.permissions.canSeeMyWorlds, + ) + addProperty( + "canJoinAutomatically", + friend.permissions.canJoinAutomatically, + ) + }, + ) + }) + } + val root = JsonObject().apply { + addProperty("version", WIRE_VERSION) + add("friends", entries) + } + writeAtomic(GSON.toJson(root)) + } + + 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 val friendsFile: Path + get() = directory.resolve(FILE_NAME) + + companion object { + const val FILE_NAME = "friends.json" + private const val WIRE_VERSION = 1 + private const val MAX_FRIENDS = 256 + private const val MAX_DISPLAY_NAME_LENGTH = 64 + private val GSON = Gson() + + private fun isValidCapability(value: String): Boolean = + value.length in 16..512 && + value.none(Char::isWhitespace) + } +} 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..a84b315ae --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt @@ -0,0 +1,101 @@ +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 SharePreferences( + val shareWithFriends: Boolean = false, +) + +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") + if (json.requiredInt("version") != WIRE_VERSION) { + throw IOException("Share preferences version is unsupported") + } + return SharePreferences( + shareWithFriends = json.requiredBoolean("shareWithFriends"), + ) + } 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) + } + 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 WIRE_VERSION = 1 + private val GSON = Gson() + } +} 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..b5c932705 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt @@ -0,0 +1,141 @@ +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.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 `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) + 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 `removing a friend revokes the locally stored relationship`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + val removed = store.remove(PEER_ID) + + assertTrue(removed) + assertTrue(FriendStore(tempDir).all().isEmpty()) + assertFalse(store.remove(PEER_ID)) + } + + @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), + ): String { + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = SHARE_ID, + expiresAtEpochMillis = expiresAt.toEpochMilli(), + connectAddress = CONNECT_ADDRESS, + peerId = PEER_ID, + internetDirectEnabled = false, + directCandidates = emptyList(), + capability = CAPABILITY, + ) + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + KEY_PAIR.public.encoded, + ) + val signature = Signature.getInstance("Ed25519").run { + initSign(KEY_PAIR.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = KEY_PAIR.public.encoded, + signature = signature, + ), + ) + } + + 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" + 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..c9f73ce5e --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt @@ -0,0 +1,25 @@ +package com.minekube.connect.share.friend + +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertFalse +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) + } +} 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 index 54839afba..df8084167 100644 --- 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 @@ -3,29 +3,49 @@ package com.minekube.connect.share.fabric import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class ConnectShareRuntime( private val scope: CoroutineScope, private val stopShare: suspend () -> Unit, + private val resumeShare: suspend () -> Unit = {}, private val worldAvailabilityChanged: (Boolean) -> Unit = {}, ) { 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 shouldStop = synchronized(lock) { + val transition = synchronized(lock) { val previous = currentWorldIdentity - currentWorldIdentity = if (worldAvailable) identity else null - previous != null && - (!worldAvailable || previous != currentWorldIdentity) + val current = if (worldAvailable) identity else null + currentWorldIdentity = current + if (previous == current) { + null + } else { + WorldTransition( + stopPrevious = previous != null, + resumeCurrent = current != null, + ) + } } - worldAvailabilityChanged(worldAvailable) - if (shouldStop) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { - stopShare() + if (transition == null) { + worldAvailabilityChanged(worldAvailable) + return + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + lifecycle.withLock { + if (transition.stopPrevious) { + stopShare() + } + worldAvailabilityChanged(worldAvailable) + if (transition.resumeCurrent) { + resumeShare() + } } } } @@ -39,11 +59,18 @@ class ConnectShareRuntime( worldAvailabilityChanged(false) if (shouldStop) { scope.launch(start = CoroutineStart.UNDISPATCHED) { - stopShare() + 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/FabricDirectShareIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt index cbb63d97e..974207b5b 100644 --- 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 @@ -7,6 +7,8 @@ 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 @@ -17,17 +19,14 @@ import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress import java.nio.file.Path -import java.security.SecureRandom import java.time.Instant -import java.util.Base64 import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean class FabricDirectShareIngress private constructor( private val nodeFactory: () -> FabricDirectNode, private val now: () -> Instant, - private val shareId: () -> UUID, - private val capability: () -> String, + private val accessIdentity: () -> ShareAccessIdentity, private val displayName: () -> String, private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, ) : DirectShareIngress { @@ -41,8 +40,9 @@ class FabricDirectShareIngress private constructor( ) }, now = Instant::now, - shareId = UUID::randomUUID, - capability = ::newCapability, + accessIdentity = ShareAccessIdentityStore( + dataDirectory, + )::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, ) @@ -54,8 +54,9 @@ class FabricDirectShareIngress private constructor( ): DirectShareHandle { val node = nodeFactory() try { - val id = shareId() - val secret = capability() + val access = accessIdentity() + val id = access.shareId + val secret = access.capability val host = node.startHost( DirectP2pHostConfig( id.toString(), @@ -132,16 +133,16 @@ class FabricDirectShareIngress private constructor( ) = FabricDirectShareIngress( nodeFactory = nodeFactory, now = now, - shareId = shareId, - capability = capability, + accessIdentity = { + ShareAccessIdentity( + shareId = shareId(), + capability = capability(), + ) + }, displayName = displayName, localSocket = localSocket, ) - private fun newCapability(): String = ByteArray(CAPABILITY_BYTES) - .also(SecureRandom()::nextBytes) - .let(Base64.getUrlEncoder().withoutPadding()::encodeToString) - private fun openTaggedLoopbackSocket( target: SocketAddress, session: DirectP2pSession, @@ -177,7 +178,6 @@ class FabricDirectShareIngress private constructor( private const val DEFAULT_DISPLAY_NAME = "Minecraft world" private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" - private const val CAPABILITY_BYTES = 32 private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L private const val LOCAL_CONNECT_TIMEOUT_MILLIS = 3_000 } 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 index 0b6c727b0..efde07026 100644 --- 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 @@ -7,6 +7,8 @@ import com.minekube.connect.share.VersionedMinecraftBridge import com.minekube.connect.share.admission.AdmissionController import com.minekube.connect.share.fabric.ui.ShareViewModel import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +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.util.MessageFormatter import java.nio.file.Path @@ -53,6 +55,13 @@ object FabricShareBootstrap { 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 validator = WatchEndpointCredentialValidator( client = httpClient, watchUrl = watchHttpUrl(environment), @@ -86,6 +95,13 @@ object FabricShareBootstrap { shareState = coordinator.state, pendingAdmissions = admission.pending, initialWorldAvailable = worldAvailable, + initialShareWithFriendsEnabled = + initialPreferences.shareWithFriends, + persistShareWithFriendsEnabled = { enabled -> + preferencesStore.save( + SharePreferences(shareWithFriends = enabled), + ) + }, identityActions = StoredEndpointIdentityUiActions( store = identityStore, validator = validator, @@ -100,6 +116,7 @@ object FabricShareBootstrap { stopShare = { coordinator.worldReplaced() }, + resumeShare = viewModel::resumeIfEnabled, worldAvailabilityChanged = viewModel::setWorldAvailable, ) return ConnectShareInstallation( 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 index 627c8a39a..1bf9547da 100644 --- 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 @@ -49,6 +49,7 @@ data class ShareUiState( val shareState: ShareState, val options: ShareOptions, val pendingAdmissions: List, + val shareWithFriendsEnabled: Boolean = false, val identity: EndpointIdentitySummary? = null, val importDraft: IdentityImportDraft = IdentityImportDraft(), val operationInProgress: Boolean = false, @@ -107,6 +108,8 @@ class ShareViewModel( pendingAdmissions: StateFlow>, initialWorldAvailable: Boolean, private val identityActions: EndpointIdentityUiActions, + initialShareWithFriendsEnabled: Boolean = false, + private val persistShareWithFriendsEnabled: (Boolean) -> Unit = {}, private val startShare: suspend (ShareOptions) -> Either, private val stopShare: suspend () -> Either, @@ -121,6 +124,7 @@ class ShareViewModel( allowCheats = false, ), pendingAdmissions = pendingAdmissions.value, + shareWithFriendsEnabled = initialShareWithFriendsEnabled, ), ) @@ -185,14 +189,8 @@ class ShareViewModel( if (!state.value.startEnabled) return scope.launch(start = CoroutineStart.UNDISPATCHED) { runOperation { - startShare(state.value.options).fold( - ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } - }, - ifRight = { - update { copy(safeMessage = null) } - }, - ) + setShareWithFriendsEnabled(true) + startCurrentWorld() } } } @@ -200,18 +198,27 @@ class ShareViewModel( fun stop() { scope.launch(start = CoroutineStart.UNDISPATCHED) { runOperation { - stopShare().fold( - ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } - }, - ifRight = { - update { copy(safeMessage = null) } - }, - ) + try { + setShareWithFriendsEnabled(false) + } finally { + stopCurrentWorld() + } } } } + suspend fun resumeIfEnabled() { + if ( + !state.value.shareWithFriendsEnabled || + !state.value.startEnabled + ) { + return + } + runOperation { + startCurrentWorld() + } + } + fun allow(requestId: UUID) { answerAdmission(requestId, true) } @@ -298,6 +305,33 @@ class ShareViewModel( ) } + 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.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + + private suspend fun stopCurrentWorld() { + stopShare().fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + private suspend fun runOperation(operation: suspend () -> Unit) { update { copy(operationInProgress = true) } try { 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 index fb947fb2a..1e7eb4d6c 100644 --- 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 @@ -41,4 +41,30 @@ class ConnectShareRuntimeTest { assertEquals(1, stopCalls) } + + @Test + fun `enabled sharing resumes when the host enters or switches worlds`() = runTest { + val lifecycle = mutableListOf() + val runtime = ConnectShareRuntime( + scope = backgroundScope, + stopShare = { + lifecycle += "stop" + }, + resumeShare = { + lifecycle += "resume" + }, + ) + + 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/FabricDirectShareIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt index 2904d3820..32c4c68ea 100644 --- 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 @@ -140,9 +140,9 @@ class FabricDirectShareIngressTest { displayName = { "First World" }, ) val first = firstIngress.start(OPTIONS, target, null) - val firstPeerId = assertIs>( + val firstInvite = assertIs>( ShareInviteCodec.decode(first.invitation), - ).value.payload.peerId + ).value first.close() Libp2pRuntime.close() @@ -151,11 +151,16 @@ class FabricDirectShareIngressTest { displayName = { "Second World" }, ) val second = secondIngress.start(OPTIONS, target, null) - val secondPeerId = assertIs>( + val secondInvite = assertIs>( ShareInviteCodec.decode(second.invitation), - ).value.payload.peerId + ).value - assertEquals(firstPeerId, secondPeerId) + 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() } 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 index 29eabbf0b..39a747674 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -130,6 +131,48 @@ class ShareViewModelTest { ) } + @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 `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) + } + private fun TestScope.viewModel( shareState: MutableStateFlow = MutableStateFlow(ShareState.Idle), @@ -139,20 +182,27 @@ class ShareViewModelTest { identityActions: EndpointIdentityUiActions = FakeIdentityActions(localIdentity()), answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, + initialShareWithFriends: Boolean = false, + persistShareWithFriends: (Boolean) -> Unit = {}, + startShare: + suspend (ShareOptions) -> Either = + { options -> + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "${options.maxGuests}.example.test", + ), + ) + }, ) = ShareViewModel( scope = backgroundScope, shareState = shareState, pendingAdmissions = pending, initialWorldAvailable = worldAvailable, identityActions = identityActions, - startShare = { options -> - Either.Right( - ShareState.Sharing( - endpoint = "share", - address = "${options.maxGuests}.example.test", - ), - ) - }, + initialShareWithFriendsEnabled = initialShareWithFriends, + persistShareWithFriendsEnabled = persistShareWithFriends, + startShare = startShare, stopShare = { Either.Right(Unit) }, answerAdmission = answerAdmission, ) From a0a635ee82ed247ace8181182399691a8ee27ec7 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 01:40:49 +0200 Subject: [PATCH 031/188] feat(share): add persistent friend sharing --- .../connect/tunnel/p2p/DirectP2pNode.java | 12 + .../tunnel/p2p/DirectP2pNodeRuntime.java | 13 +- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 30 + .../share/admission/AdmissionController.kt | 4 + .../share/admission/AdmissionIdentity.kt | 3 + .../share/admission/NewAdmissionTracker.kt | 17 + .../connect/share/friend/FriendStore.kt | 18 +- .../admission/AdmissionControllerTest.kt | 28 + .../admission/NewAdmissionTrackerTest.kt | 33 ++ .../connect/share/friend/FriendStoreTest.kt | 18 + .../v1_21_11/ConnectShare12111Client.kt | 89 ++- .../fabric/v1_21_11/FriendCardNetworking.kt | 92 +++ .../fabric/v1_21_11/FriendCardPayload.kt | 55 ++ .../v1_21_11/Minecraft12111LoginBridge.kt | 1 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 550 ++++++++++++++---- .../share/fabric/v1_21_11/ShareSetupScreen.kt | 32 +- .../fabric/v1_21_11/ShareStatusScreen.kt | 57 +- .../assets/connect-share/lang/de_de.json | 64 +- .../assets/connect-share/lang/en_us.json | 64 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 24 + .../fabric/v1_21_11/FriendCardPayloadTest.kt | 41 ++ .../fabric/v26_2/ConnectShare262Client.kt | 89 ++- .../fabric/v26_2/FriendCardNetworking.kt | 92 +++ .../share/fabric/v26_2/FriendCardPayload.kt | 55 ++ .../fabric/v26_2/Minecraft262LoginBridge.kt | 1 + .../share/fabric/v26_2/ShareJoinScreen.kt | 542 +++++++++++++---- .../share/fabric/v26_2/ShareSetupScreen.kt | 26 +- .../share/fabric/v26_2/ShareStatusScreen.kt | 47 +- .../assets/connect-share/lang/de_de.json | 64 +- .../assets/connect-share/lang/en_us.json | 64 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 24 + .../fabric/v26_2/FriendCardPayloadTest.kt | 41 ++ .../share/fabric/ApprovedJoinTracker.kt | 86 +++ .../share/fabric/ConnectShareClient.kt | 17 + .../share/fabric/FabricConnectIngress.kt | 12 +- .../fabric/FabricLoginAdmissionRegistry.kt | 2 + .../fabric/FabricSessionAdmissionGate.kt | 17 +- .../share/fabric/FabricShareBootstrap.kt | 35 +- .../share/fabric/FabricShareBrowser.kt | 71 ++- .../share/fabric/FriendCardExchangeConsent.kt | 34 ++ .../connect/share/fabric/FriendCardIssuer.kt | 93 +++ .../share/fabric/FriendPresenceMonitor.kt | 88 +++ .../share/fabric/MinecraftStatusProbe.kt | 205 +++++++ .../share/fabric/ui/FriendsViewModel.kt | 163 ++++++ .../share/fabric/ApprovedJoinTrackerTest.kt | 71 +++ .../fabric/FabricSessionAdmissionGateTest.kt | 30 +- .../share/fabric/FabricShareBrowserTest.kt | 91 +++ .../fabric/FriendCardExchangeConsentTest.kt | 62 ++ .../share/fabric/FriendCardIssuerTest.kt | 115 ++++ .../share/fabric/FriendPresenceMonitorTest.kt | 92 +++ .../share/fabric/MinecraftStatusProbeTest.kt | 114 ++++ .../share/fabric/ui/FriendsViewModelTest.kt | 250 ++++++++ 52 files changed, 3584 insertions(+), 354 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/admission/NewAdmissionTracker.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/admission/NewAdmissionTrackerTest.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbeTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt 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 index 838a9cc11..f13ed7146 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -33,6 +33,8 @@ */ public final class DirectP2pNode implements AutoCloseable { private Object runtime; + private Method peerId; + private Method publicKey; private Method startHost; private Method sign; private Method publish; @@ -62,6 +64,8 @@ private void initialize(Path identityFile) { 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, @@ -92,6 +96,14 @@ private void initialize(Path identityFile) { } } + 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) { 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 index fff9cf5b9..c17406317 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -118,6 +118,16 @@ final class DirectP2pNodeRuntime { .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) { @@ -152,9 +162,6 @@ synchronized DirectP2pHostInfo startHost( synchronized byte[] sign(byte[] payload) { ensureOpen(); - if (hostConfig == null) { - throw new IllegalStateException("Connect Share direct host is not started"); - } return privateKey.sign(Arrays.copyOf(payload, payload.length)); } 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 index 2f0da1ff6..9a73a21d4 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -176,6 +176,36 @@ void persistentIdentitySurvivesNodeRestarts() { 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(); 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 index c4581b50b..42400cfb7 100644 --- 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 @@ -19,6 +19,7 @@ class AdmissionController( private val maxPending: Int = 16, private val connectedCount: () -> Int, private val maxGuests: () -> Int, + private val autoApprove: (AdmissionIdentity) -> Boolean = { false }, ) { private val lock = Any() private val requests = linkedMapOf() @@ -41,6 +42,9 @@ class AdmissionController( if (connectedCount() >= maxGuests()) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } + if (autoApprove(identity)) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) + } if ( identity is AdmissionIdentity.Authenticated && identity.uuid in authenticatedApprovals 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 index 6834b92dd..2d270b391 100644 --- 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 @@ -5,12 +5,14 @@ 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( @@ -18,6 +20,7 @@ sealed interface AdmissionIdentity { override val uuid: UUID, val connectionId: String, val ingress: Ingress, + override val directPeerId: String? = null, ) : AdmissionIdentity } 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/friend/FriendStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt index b38168072..bffa267aa 100644 --- 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 @@ -39,13 +39,14 @@ data class SavedFriend( val capability: String, val connectAddress: String?, val displayName: String, + val minecraftUuid: UUID? = null, val permissions: FriendPermissions = FriendPermissions(), ) { override fun toString(): String = "SavedFriend(peerId=$peerId, publicKey=, " + "shareId=$shareId, capability=, " + "connectAddress=$connectAddress, displayName=$displayName, " + - "permissions=$permissions)" + "minecraftUuid=$minecraftUuid, permissions=$permissions)" } sealed interface FriendStoreError { @@ -106,6 +107,7 @@ class FriendStore( capability = invite.payload.capability, connectAddress = invite.payload.connectAddress, displayName = existing?.displayName ?: normalizedName, + minecraftUuid = existing?.minecraftUuid, permissions = existing?.permissions ?: FriendPermissions(), ) write( @@ -134,6 +136,14 @@ class FriendStore( friend.copy(permissions = permissions) } + @Synchronized + fun linkMinecraftIdentity( + peerId: String, + minecraftUuid: UUID, + ): Either = update(peerId) { friend -> + friend.copy(minecraftUuid = minecraftUuid) + } + @Synchronized fun remove(peerId: String): Boolean { val current = read() @@ -199,6 +209,8 @@ class FriendStore( val capability = json.requiredString("capability") val connectAddress = json.optionalString("connectAddress") val displayName = json.requiredString("displayName") + val minecraftUuid = json.optionalString("minecraftUuid") + ?.let(UUID::fromString) if ( peerId.isBlank() || publicKey.isBlank() || @@ -217,6 +229,7 @@ class FriendStore( capability = capability, connectAddress = connectAddress, displayName = displayName, + minecraftUuid = minecraftUuid, permissions = FriendPermissions( notifyWhenOnline = permissions.requiredBoolean("notifyWhenOnline"), canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), @@ -242,6 +255,9 @@ class FriendStore( addProperty("connectAddress", it) } addProperty("displayName", friend.displayName) + friend.minecraftUuid?.let { + addProperty("minecraftUuid", it.toString()) + } add( "permissions", JsonObject().apply { 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 index 0e435d768..5af01224c 100644 --- 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 @@ -166,15 +166,43 @@ class AdmissionControllerTest { 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()) + } + private fun kotlinx.coroutines.test.TestScope.controller( connectedCount: () -> Int = { 0 }, maxGuests: () -> Int = { 8 }, + autoApprove: (AdmissionIdentity) -> Boolean = { false }, ) = AdmissionController( scope = backgroundScope, timeout = 30.seconds, maxPending = 16, connectedCount = connectedCount, maxGuests = maxGuests, + autoApprove = autoApprove, ) private fun authenticated( 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/friend/FriendStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt index b5c932705..106ea2738 100644 --- 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 @@ -69,6 +69,24 @@ class FriendStoreTest { assertTrue(managed.permissions.canJoinAutomatically) } + @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) 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 index d65b6cd5f..e1bc245a9 100644 --- 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 @@ -1,29 +1,51 @@ package com.minekube.connect.share.fabric.v1_21_11 +import com.minekube.connect.share.admission.NewAdmissionTracker import com.minekube.connect.share.fabric.ConnectShareClient 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.FriendOnlineTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch 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.network.chat.Component class ConnectShare12111Client : ClientModInitializer { override fun onInitializeClient() { val client = Minecraft.getInstance() val dispatcher = client.asCoroutineDispatcher() val scope = CoroutineScope(SupervisorJob() + dispatcher) + val dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val remotePresence = FriendPresenceMonitor(friendStore) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } val installation = FabricShareBootstrap.create( scope = scope, - dataDirectory = FabricLoader.getInstance().configDir - .resolve("minekube-connect-share"), + dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), worldAvailable = client.hasSingleplayerServer(), playerCount = { @@ -33,10 +55,13 @@ class ConnectShare12111Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world" }, - bridgeFactory = { admission, admissionScope -> + bridgeFactory = { admission, admissionScope, approvedJoins -> Minecraft12111Bridge { FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission(admission), + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), scope = admissionScope, ) } @@ -56,11 +81,30 @@ class ConnectShare12111Client : ClientModInitializer { guestScreens = { parent -> val parentScreen = parent as Screen client.execute { - client.setScreen(ShareJoinScreen(parentScreen)) + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = FriendsViewModel( + friendStore, + ), + browser = FabricShareBrowser(dataDirectory), + remotePresence = remotePresence, + ), + ) } }, ) + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = FriendCardReceiver(friendStore), + approvedJoins = installation.approvedJoins, + ) ConnectShareClient.install(installation) + val admissionNotifications = NewAdmissionTracker() + val friendNotifications = FriendOnlineTracker() + val admissionToastId = SystemToast.SystemToastId() + val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> ConnectShareClient.integratedWorldChanged( @@ -70,9 +114,44 @@ class ConnectShare12111Client : ClientModInitializer { ConnectShareClient.guestConnectionChanged( minecraft.connection != null, ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.toastManager, + admissionToastId, + Component.translatable( + "connect_share.notification.join_request", + ), + Component.translatable( + "connect_share.notification.join_request_detail", + request.identity.name, + ), + ) + } + friendNotifications.update( + remotePresence.state.value, + ).firstOrNull()?.let { friend -> + SystemToast.add( + minecraft.toastManager, + friendToastId, + Component.translatable( + "connect_share.notification.friend_online", + ), + Component.translatable( + "connect_share.notification.friend_online_detail", + friend.displayName, + ), + ) + } } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() + scope.cancel() } } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 30_000L + } } 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..a781e05ba --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt @@ -0,0 +1,92 @@ +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, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name(), + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + ) + } + } + 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 -> + if (!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), + ) + } + } + } + } + } + } +} 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..a2a74ea40 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +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, +) : 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 -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + ) + }, + ) + } +} + +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/Minecraft12111LoginBridge.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt index a23ec7a7b..8974cff9e 100644 --- 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 @@ -124,6 +124,7 @@ object Minecraft12111LoginBridge { connectionId = session.connectionId(), minecraftAuthenticated = minecraftAuthenticated, ingress = session.route().toIngress(), + directPeerId = session.peerId(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) 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 index 0f6a77f31..be999fb51 100644 --- 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 @@ -1,9 +1,13 @@ package com.minekube.connect.share.fabric.v1_21_11 import com.minekube.connect.share.fabric.ConnectShareClient -import com.minekube.connect.share.fabric.DiscoveredLanShare import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -16,6 +20,7 @@ import net.minecraft.client.gui.components.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -24,84 +29,219 @@ import net.minecraft.network.chat.Component class ShareJoinScreen( private val parent: Screen, -) : Screen(Component.translatable("connect_share.join.title")) { - private val browser = FabricShareBrowser() + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, +) : 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 joinButton: Button? = null - private var invitationValue = "" - private var selectedLanAddress: String? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null private var safeMessage: String? = null - private var discoveredFingerprint = 0 + private var fingerprint = 0 private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false private var transferred = false - private var selectingDiscovered = false override fun init() { if (scope == null) { scope = CoroutineScope( - SupervisorJob() + minecraft!!.asCoroutineDispatcher(), + SupervisorJob() + minecraft.asCoroutineDispatcher(), ) browser.start().onLeft { safeMessage = it.safeMessage } } - discoveredFingerprint = browser.discovered.value.hashCode() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + when (mode) { + Mode.FRIENDS -> minecraft.setScreen(parent) + Mode.ADD, + Mode.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } - addRenderableWidget(centered(title, 16)) + private fun buildFriends() { addRenderableWidget( centered( - Component.translatable("connect_share.join.description"), + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.description"), 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + + val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) + if (saved.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.empty"), + 82, + ).setMaxWidth(CONTENT_WIDTH), + ) + } else { + saved.forEachIndexed { index, friend -> + val y = 58 + index * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 54) + .setMaxWidth(CONTENT_WIDTH), + ) + } + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, ), ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add_description"), + 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) invitationBox = addRenderableWidget( EditBox( font, width / 2 - 155, - 52, + 84, 310, 20, Component.translatable("connect_share.join.invitation"), ).apply { setMaxLength(MAX_INVITATION_LENGTH) - setHint(Component.translatable("connect_share.join.invitation_hint")) + setHint( + Component.translatable( + "connect_share.join.invitation_hint", + ), + ) setValue(invitationValue) - setResponder { value -> - invitationValue = value - if (!selectingDiscovered) { - selectedLanAddress = null - } + setResponder { + invitationValue = it refresh() } }, ) - - val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) - if (discovered.isEmpty()) { - addRenderableWidget( - centered( - Component.translatable("connect_share.join.scanning"), - 88, - ), - ) - } else { - discovered.forEachIndexed { index, share -> - addRenderableWidget( - Button.builder(discoveredLabel(share)) { - selectDiscovered(share) - }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) - .build(), - ) - } - } - offlineMode = addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.join.offline"), font, - ).pos(width / 2 - 155, 134) - .selected(offlineMode?.selected() ?: false) + ).pos(width / 2 - 155, 112) + .selected(offlineSelected) + .onValueChange { _, selected -> + offlineSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -115,8 +255,11 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.join.internet"), font, - ).pos(width / 2 - 155, 156) - .selected(internetDirect?.selected() ?: false) + ).pos(width / 2 - 155, 134) + .selected(internetSelected) + .onValueChange { _, selected -> + internetSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -126,91 +269,211 @@ class ShareJoinScreen( ) .build(), ) - - safeMessage?.let { - addRenderableWidget( - centered(Component.literal(it), 182).setMaxWidth(310), - ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - joinButton = addRenderableWidget( - Button.builder(Component.translatable("connect_share.join.join")) { - join() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save"), + ) { + if (friends.accept(invitationValue, nameValue)) { + scope?.launch { + remotePresence.refresh() + } + invitationValue = "" + nameValue = "" + mode = Mode.FRIENDS + rebuildWidgets() + } else { + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) refresh() } - override fun tick() { - super.tick() - val next = browser.discovered.value.hashCode() - if (next != discoveredFingerprint) { - invitationValue = invitationBox?.value.orEmpty() + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS rebuildWidgets() - } else { - refresh() + return } - } - - override fun onClose() { - minecraft?.setScreen(parent) - } - - override fun removed() { - scope?.cancel() - scope = null - if (!transferred) { - browser.close() + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 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(width / 2 - 155, 82) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + val autoJoin = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.auto_join"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canJoinAutomatically) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.auto_join.tooltip", + ), + ), + ) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 138) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - super.removed() + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = autoJoin.selected(), + ), + ) + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + minecraft.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + friends.remove(friend.peerId) + mode = Mode.FRIENDS + selectedPeerId = null + } + minecraft.setScreen(this) + }, + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + ), + ) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() } - private fun selectDiscovered(share: DiscoveredLanShare) { - selectedLanAddress = share.lanAddress - invitationValue = share.invitationUri - selectingDiscovered = true - invitationBox?.value = invitationValue - selectingDiscovered = false + 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(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } } - private fun join() { + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true + joiningPeerId = null + reciprocalPairing = false safeMessage = null refresh() scope?.launch { browser.join( invitationUri = invitationValue, - lanAddress = selectedLanAddress, - internetOptIn = internetDirect?.selected() == true, - authMode = if (offlineMode?.selected() == true) { - DirectP2pAuthMode.OFFLINE - } else { - DirectP2pAuthMode.ONLINE - }, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), ).fold( - ifLeft = { failure -> - joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, + ifLeft = ::joinFailed, ifRight = ::connect, ) } } + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.safeMessage + rebuildWidgets() + } + private fun connect(target: GuestJoinTarget) { - val client = minecraft ?: run { - target.close() - joining = false - return - } + val client = minecraft val address = when (target) { is GuestJoinTarget.Connect -> ServerAddress.parseString(target.publicAddress) @@ -227,24 +490,92 @@ class ShareJoinScreen( } else { browser.close() } + val joiningFriend = friends.state.value.friends.firstOrNull { + it.peerId == joiningPeerId + } val data = ServerData( - "Connect Share", + joiningFriend?.displayName ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) - ConnectScreen.startConnecting(parent, client, address, data, false, null) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds, + ) + if (exchangeFriendCard) { + ConnectShareClient.armFriendCardExchange() + } + ConnectScreen.startConnecting( + parent, + client, + address, + data, + false, + null, + ) } private fun refresh() { - joinButton?.active = !joining && invitationValue.isNotBlank() + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = !joining && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) } - private fun discoveredLabel(share: DiscoveredLanShare): Component = - Component.translatable( - "connect_share.join.discovered", - share.displayName, - ) + private fun friendLabel(friend: FriendSummary): Component = when { + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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) @@ -258,8 +589,15 @@ class ShareJoinScreen( ) } + private enum class Mode { + FRIENDS, + ADD, + MANAGE, + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_SHARES = 2 + const val MAX_VISIBLE_FRIENDS = 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/ShareSetupScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt index d5eb538d6..f3c900d47 100644 --- 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 @@ -19,17 +19,17 @@ class ShareSetupScreen( override fun init() { val current = viewModel.state.value - minecraft?.singleplayerServer?.let { server -> + minecraft.singleplayerServer?.let { server -> viewModel.setGameMode(server.defaultGameType.toShareGameMode()) viewModel.setAllowCheats(server.worldData.isAllowCommands) } - addRenderableWidget(centered(title, 32)) + addRenderableWidget(centered(title, 18)) addRenderableWidget( centered( Component.translatable("connect_share.setup.description"), - 52, - ), + 36, + ).setMaxWidth(CONTENT_WIDTH), ) addRenderableWidget( CycleButton.builder( @@ -40,7 +40,7 @@ class ShareSetupScreen( ).withValues(ShareGameMode.entries) .create( width / 2 - 155, - 78, + 68, 150, 20, Component.translatable("selectWorld.gameMode"), @@ -50,7 +50,7 @@ class ShareSetupScreen( CycleButton.onOffBuilder(current.options.allowCheats) .create( width / 2 + 5, - 78, + 68, 150, 20, Component.translatable("selectWorld.allowCommands"), @@ -63,7 +63,7 @@ class ShareSetupScreen( ).withValues((1..16).toList()) .create( width / 2 - 75, - 110, + 96, 150, 20, Component.translatable("connect_share.setup.max_guests"), @@ -73,7 +73,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 138) + ).pos(width / 2 - 155, 126) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,12 +87,20 @@ class ShareSetupScreen( ) .build(), ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ).setMaxWidth(CONTENT_WIDTH), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), ) { viewModel.start() - minecraft?.setScreen(ShareStatusScreen(parent)) + minecraft.setScreen(ShareStatusScreen(parent)) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -109,7 +117,7 @@ class ShareSetupScreen( } override fun onClose() { - minecraft?.setScreen(parent) + minecraft.setScreen(parent) } private fun refresh() { @@ -120,6 +128,10 @@ class ShareSetupScreen( val textWidth = font.width(message) return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + + private companion object { + const val CONTENT_WIDTH = 310 + } } private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { 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 index 15335c29a..d820d99de 100644 --- 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 @@ -43,44 +43,57 @@ class ShareStatusScreen( Component.translatable("connect_share.status.copy_invitation"), ) { sharing?.invitation?.let( - minecraft!!.keyboardHandler::setClipboard, + minecraft.keyboardHandler::setClipboard, ) - }.bounds(width / 2 - 155, 48, 150, 20).build(), + }.bounds(width / 2 - 155, 50, 150, 20).build(), ) copyInvitation.active = sharing?.invitation != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), ) { - sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 48, 150, 20).build(), + sharing?.address?.let(minecraft.keyboardHandler::setClipboard) + }.bounds(width / 2 + 5, 50, 150, 20).build(), ) copyAddress.active = sharing?.address != null - sharing?.let { + if (sharing != null) { addRenderableWidget( centered( Component.translatable( - "connect_share.status.routes", - availability(it.connectAvailable), - availability(it.lanDirectAvailable), - availability(it.internetDirectAvailable), + "connect_share.status.link_help", ), - 76, - ).setMaxWidth(310), + 78, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ).setMaxWidth(CONTENT_WIDTH), ) } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft?.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 92, 200, 20).build(), + minecraft.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 166) / 38).coerceIn(1, 3) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 120 + index * 38 + val y = 124 + index * 26 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> listOfNotNull( @@ -95,7 +108,6 @@ class ShareStatusScreen( val label = Component.translatable( "connect_share.status.request", identity.name, - identity.uuid.toString(), badge, ) addRenderableWidget( @@ -126,7 +138,7 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 120 + visibleRows * 38, + 124 + visibleRows * 26, ), ) } else if (pending.isEmpty()) { @@ -141,7 +153,7 @@ class ShareStatusScreen( addRenderableWidget( Button.builder(Component.translatable("connect_share.status.stop")) { viewModel.stop() - minecraft?.setScreen(parent) + minecraft.setScreen(parent) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -160,7 +172,7 @@ class ShareStatusScreen( } override fun onClose() { - minecraft?.setScreen(parent) + minecraft.setScreen(parent) } private fun centered(message: Component, y: Int): StringWidget { @@ -168,9 +180,6 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } - private fun availability(available: Boolean): Component = - Component.translatable(if (available) "options.on" else "options.off") - private fun Ingress.displayName(): String = when (this) { Ingress.CONNECT -> "connect" Ingress.DIRECT_LAN -> "lan" @@ -184,4 +193,8 @@ class ShareStatusScreen( ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } + + private companion object { + const val CONTENT_WIDTH = 310 + } } 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 index 5cc09956c..1653c6127 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Mit Connect teilen", - "connect_share.menu.active": "Connect Share aktiv", - "connect_share.menu.join": "Connect Share beitreten", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", "connect_share.setup.max_guests": "Maximale Gäste", - "connect_share.setup.internet": "Direkte Internetverbindungen erlauben", + "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.start": "Teilen starten", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Mit Freunden teilen", "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": "Connect Share", - "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", - "connect_share.status.copy_invitation": "Einladung kopieren", - "connect_share.status.copy_address": "Vanilla-Adresse kopieren", - "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", + "connect_share.status.title": "Welt ist für Freunde 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": "Beitrittsanfragen", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Erlauben", "connect_share.status.deny": "Ablehnen", "connect_share.status.more": "%s weitere Anfragen", - "connect_share.status.waiting": "Warte auf Freunde…", - "connect_share.status.stop": "Teilen beenden", + "connect_share.status.waiting": "Niemand wartet auf den Beitritt.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpunkt-Identität…", + "connect_share.friends.title": "Freunde", + "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.empty": "Noch keine Freunde. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.save": "Freund speichern", + "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.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.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": "Freund möchte beitreten", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", + "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", 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 index b0a048bbb..7abb70291 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Share with Connect", - "connect_share.menu.active": "Connect Share active", - "connect_share.menu.join": "Join Connect Share", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", "connect_share.setup.max_guests": "Maximum guests", - "connect_share.setup.internet": "Allow direct internet connections", + "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.start": "Start sharing", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Share with friends", "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": "Connect Share", - "connect_share.status.address": "Join address: %s", - "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", - "connect_share.status.copy_invitation": "Copy invitation", - "connect_share.status.copy_address": "Copy vanilla address", - "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", + "connect_share.status.title": "World ready for friends", + "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": "Join requests", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Allow", "connect_share.status.deny": "Deny", "connect_share.status.more": "%s more requests", - "connect_share.status.waiting": "Waiting for friends to join…", - "connect_share.status.stop": "Stop sharing", + "connect_share.status.waiting": "No one is waiting to join.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpoint identity…", + "connect_share.friends.title": "Friends", + "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.empty": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.manage": "Manage", + "connect_share.friends.add": "Add friend", + "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.name": "Friend name", + "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.save": "Save friend", + "connect_share.friends.join_once": "Join once", + "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.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.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": "Friend wants to join", + "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is online. Open Friends 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", 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 index 4dcbfedd2..bba0ddf50 100644 --- 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 @@ -17,6 +17,26 @@ 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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" in language, + ) + } + } + @Test fun `remapped artifact is self contained and isolates networking runtime`() { JarFile(artifact().toFile()).use { jar -> @@ -25,6 +45,10 @@ class Fabric12111ArtifactTest { 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/") && 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..79161088e --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import io.netty.buffer.Unpooled +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 buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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/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 index c39cf33f9..34910bf48 100644 --- 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 @@ -1,19 +1,32 @@ package com.minekube.connect.share.fabric.v26_2 +import com.minekube.connect.share.admission.NewAdmissionTracker import com.minekube.connect.share.fabric.ConnectShareClient 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.FriendOnlineTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch 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.network.chat.Component class ConnectShare262Client : ClientModInitializer { override fun onInitializeClient() { @@ -21,10 +34,19 @@ class ConnectShare262Client : ClientModInitializer { val scope = CoroutineScope( SupervisorJob() + client.asCoroutineDispatcher(), ) + val dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val remotePresence = FriendPresenceMonitor(friendStore) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } val installation = FabricShareBootstrap.create( scope = scope, - dataDirectory = FabricLoader.getInstance().configDir - .resolve("minekube-connect-share"), + dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), worldAvailable = client.hasSingleplayerServer(), playerCount = { @@ -34,10 +56,13 @@ class ConnectShare262Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world" }, - bridgeFactory = { admission, admissionScope -> + bridgeFactory = { admission, admissionScope, approvedJoins -> Minecraft262Bridge { FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission(admission), + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), scope = admissionScope, ) } @@ -57,11 +82,30 @@ class ConnectShare262Client : ClientModInitializer { guestScreens = { parent -> val parentScreen = parent as Screen client.execute { - client.gui.setScreen(ShareJoinScreen(parentScreen)) + client.gui.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = FriendsViewModel( + friendStore, + ), + browser = FabricShareBrowser(dataDirectory), + remotePresence = remotePresence, + ), + ) } }, ) + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = FriendCardReceiver(friendStore), + approvedJoins = installation.approvedJoins, + ) ConnectShareClient.install(installation) + val admissionNotifications = NewAdmissionTracker() + val friendNotifications = FriendOnlineTracker() + val admissionToastId = SystemToast.SystemToastId() + val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> ConnectShareClient.integratedWorldChanged( @@ -71,9 +115,44 @@ class ConnectShare262Client : ClientModInitializer { ConnectShareClient.guestConnectionChanged( minecraft.connection != null, ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.gui.toastManager(), + admissionToastId, + Component.translatable( + "connect_share.notification.join_request", + ), + Component.translatable( + "connect_share.notification.join_request_detail", + request.identity.name, + ), + ) + } + friendNotifications.update( + remotePresence.state.value, + ).firstOrNull()?.let { friend -> + SystemToast.add( + minecraft.gui.toastManager(), + friendToastId, + Component.translatable( + "connect_share.notification.friend_online", + ), + Component.translatable( + "connect_share.notification.friend_online_detail", + friend.displayName, + ), + ) + } } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() + scope.cancel() } } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 30_000L + } } 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..f5b6ef4e9 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt @@ -0,0 +1,92 @@ +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, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name(), + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + ) + } + } + 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 -> + if (!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), + ) + } + } + } + } + } + } +} 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..54f6f1350 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.fabric.v26_2 + +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, +) : 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 -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + ) + }, + ) + } +} + +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/Minecraft262LoginBridge.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt index f3556f0ef..a68f2b899 100644 --- 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 @@ -124,6 +124,7 @@ object Minecraft262LoginBridge { connectionId = session.connectionId(), minecraftAuthenticated = minecraftAuthenticated, ingress = session.route().toIngress(), + directPeerId = session.peerId(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) 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 index 76fbafa2e..b0ee9b699 100644 --- 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 @@ -1,9 +1,13 @@ package com.minekube.connect.share.fabric.v26_2 import com.minekube.connect.share.fabric.ConnectShareClient -import com.minekube.connect.share.fabric.DiscoveredLanShare import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -16,6 +20,7 @@ import net.minecraft.client.gui.components.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -24,20 +29,29 @@ import net.minecraft.network.chat.Component class ShareJoinScreen( private val parent: Screen, -) : Screen(Component.translatable("connect_share.join.title")) { - private val browser = FabricShareBrowser() + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, +) : 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 joinButton: Button? = null - private var invitationValue = "" - private var selectedLanAddress: String? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null private var safeMessage: String? = null - private var discoveredFingerprint = 0 + private var fingerprint = 0 private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false private var transferred = false - private var selectingDiscovered = false override fun init() { if (scope == null) { @@ -46,62 +60,188 @@ class ShareJoinScreen( ) browser.start().onLeft { safeMessage = it.safeMessage } } - discoveredFingerprint = browser.discovered.value.hashCode() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + when (mode) { + Mode.FRIENDS -> minecraft.gui.setScreen(parent) + Mode.ADD, + Mode.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } - addRenderableWidget(centered(title, 16)) + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } + + private fun buildFriends() { addRenderableWidget( centered( - Component.translatable("connect_share.join.description"), + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.description"), 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + + val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) + if (saved.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.empty"), + 82, + ).setMaxWidth(CONTENT_WIDTH), + ) + } else { + saved.forEachIndexed { index, friend -> + val y = 58 + index * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 54) + .setMaxWidth(CONTENT_WIDTH), + ) + } + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, ), ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add_description"), + 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) invitationBox = addRenderableWidget( EditBox( font, width / 2 - 155, - 52, + 84, 310, 20, Component.translatable("connect_share.join.invitation"), ).apply { setMaxLength(MAX_INVITATION_LENGTH) - setHint(Component.translatable("connect_share.join.invitation_hint")) + setHint( + Component.translatable( + "connect_share.join.invitation_hint", + ), + ) setValue(invitationValue) - setResponder { value -> - invitationValue = value - if (!selectingDiscovered) { - selectedLanAddress = null - } + setResponder { + invitationValue = it refresh() } }, ) - - val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) - if (discovered.isEmpty()) { - addRenderableWidget( - centered( - Component.translatable("connect_share.join.scanning"), - 88, - ), - ) - } else { - discovered.forEachIndexed { index, share -> - addRenderableWidget( - Button.builder(discoveredLabel(share)) { - selectDiscovered(share) - }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) - .build(), - ) - } - } - offlineMode = addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.join.offline"), font, - ).pos(width / 2 - 155, 134) - .selected(offlineMode?.selected() ?: false) + ).pos(width / 2 - 155, 112) + .selected(offlineSelected) + .onValueChange { _, selected -> + offlineSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -115,8 +255,11 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.join.internet"), font, - ).pos(width / 2 - 155, 156) - .selected(internetDirect?.selected() ?: false) + ).pos(width / 2 - 155, 134) + .selected(internetSelected) + .onValueChange { _, selected -> + internetSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -126,85 +269,209 @@ class ShareJoinScreen( ) .build(), ) - - safeMessage?.let { - addRenderableWidget( - centered(Component.literal(it), 182).setMaxWidth(310), - ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - joinButton = addRenderableWidget( - Button.builder(Component.translatable("connect_share.join.join")) { - join() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save"), + ) { + if (friends.accept(invitationValue, nameValue)) { + scope?.launch { + remotePresence.refresh() + } + invitationValue = "" + nameValue = "" + mode = Mode.FRIENDS + rebuildWidgets() + } else { + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) refresh() } - override fun tick() { - super.tick() - val next = browser.discovered.value.hashCode() - if (next != discoveredFingerprint) { - invitationValue = invitationBox?.value.orEmpty() + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS rebuildWidgets() - } else { - refresh() + return } - } - - override fun onClose() { - minecraft.gui.setScreen(parent) - } - - override fun removed() { - scope?.cancel() - scope = null - if (!transferred) { - browser.close() + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 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(width / 2 - 155, 82) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + val autoJoin = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.auto_join"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canJoinAutomatically) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.auto_join.tooltip", + ), + ), + ) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 138) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - super.removed() + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = autoJoin.selected(), + ), + ) + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + minecraft.gui.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + friends.remove(friend.peerId) + mode = Mode.FRIENDS + selectedPeerId = null + } + minecraft.gui.setScreen(this) + }, + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + ), + ) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() } - private fun selectDiscovered(share: DiscoveredLanShare) { - selectedLanAddress = share.lanAddress - invitationValue = share.invitationUri - selectingDiscovered = true - invitationBox?.value = invitationValue - selectingDiscovered = false + 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(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } } - private fun join() { + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true + joiningPeerId = null + reciprocalPairing = false safeMessage = null refresh() scope?.launch { browser.join( invitationUri = invitationValue, - lanAddress = selectedLanAddress, - internetOptIn = internetDirect?.selected() == true, - authMode = if (offlineMode?.selected() == true) { - DirectP2pAuthMode.OFFLINE - } else { - DirectP2pAuthMode.ONLINE - }, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), ).fold( - ifLeft = { failure -> - joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, + ifLeft = ::joinFailed, ifRight = ::connect, ) } } + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.safeMessage + rebuildWidgets() + } + private fun connect(target: GuestJoinTarget) { val address = when (target) { is GuestJoinTarget.Connect -> @@ -222,24 +489,92 @@ class ShareJoinScreen( } else { browser.close() } + val joiningFriend = friends.state.value.friends.firstOrNull { + it.peerId == joiningPeerId + } val data = ServerData( - "Connect Share", + joiningFriend?.displayName ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) - ConnectScreen.startConnecting(parent, minecraft, address, data, false, null) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds, + ) + if (exchangeFriendCard) { + ConnectShareClient.armFriendCardExchange() + } + ConnectScreen.startConnecting( + parent, + minecraft, + address, + data, + false, + null, + ) } private fun refresh() { - joinButton?.active = !joining && invitationValue.isNotBlank() + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = !joining && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) } - private fun discoveredLabel(share: DiscoveredLanShare): Component = - Component.translatable( - "connect_share.join.discovered", - share.displayName, - ) + private fun friendLabel(friend: FriendSummary): Component = when { + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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) @@ -253,8 +588,15 @@ class ShareJoinScreen( ) } + private enum class Mode { + FRIENDS, + ADD, + MANAGE, + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_SHARES = 2 + const val MAX_VISIBLE_FRIENDS = 5 + const val CONTENT_WIDTH = 310 } } 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 index d0cb204d8..db90bca2e 100644 --- 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 @@ -24,12 +24,12 @@ class ShareSetupScreen( viewModel.setAllowCheats(server.worldData.isAllowCommands) } - addRenderableWidget(centered(title, 32)) + addRenderableWidget(centered(title, 18)) addRenderableWidget( centered( Component.translatable("connect_share.setup.description"), - 52, - ), + 36, + ).setMaxWidth(CONTENT_WIDTH), ) addRenderableWidget( CycleButton.builder( @@ -40,7 +40,7 @@ class ShareSetupScreen( ).withValues(ShareGameMode.entries) .create( width / 2 - 155, - 78, + 68, 150, 20, Component.translatable("selectWorld.gameMode"), @@ -50,7 +50,7 @@ class ShareSetupScreen( CycleButton.onOffBuilder(current.options.allowCheats) .create( width / 2 + 5, - 78, + 68, 150, 20, Component.translatable("selectWorld.allowCommands"), @@ -63,7 +63,7 @@ class ShareSetupScreen( ).withValues((1..16).toList()) .create( width / 2 - 75, - 110, + 96, 150, 20, Component.translatable("connect_share.setup.max_guests"), @@ -73,7 +73,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 138) + ).pos(width / 2 - 155, 126) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,6 +87,14 @@ class ShareSetupScreen( ) .build(), ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ).setMaxWidth(CONTENT_WIDTH), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), @@ -120,6 +128,10 @@ class ShareSetupScreen( val textWidth = font.width(message) return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + + private companion object { + const val CONTENT_WIDTH = 310 + } } private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { 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 index 98c096891..c3b3ace0d 100644 --- 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 @@ -45,7 +45,7 @@ class ShareStatusScreen( sharing?.invitation?.let( minecraft.keyboardHandler::setClipboard, ) - }.bounds(width / 2 - 155, 48, 150, 20).build(), + }.bounds(width / 2 - 155, 50, 150, 20).build(), ) copyInvitation.active = sharing?.invitation != null val copyAddress = addRenderableWidget( @@ -53,34 +53,47 @@ class ShareStatusScreen( Component.translatable("connect_share.status.copy_address"), ) { sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 48, 150, 20).build(), + }.bounds(width / 2 + 5, 50, 150, 20).build(), ) copyAddress.active = sharing?.address != null - sharing?.let { + if (sharing != null) { addRenderableWidget( centered( Component.translatable( - "connect_share.status.routes", - availability(it.connectAvailable), - availability(it.lanDirectAvailable), - availability(it.internetDirectAvailable), + "connect_share.status.link_help", ), - 76, - ).setMaxWidth(310), + 78, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ).setMaxWidth(CONTENT_WIDTH), ) } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 92, 200, 20).build(), + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 166) / 38).coerceIn(1, 3) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 120 + index * 38 + val y = 124 + index * 26 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> listOfNotNull( @@ -95,7 +108,6 @@ class ShareStatusScreen( val label = Component.translatable( "connect_share.status.request", identity.name, - identity.uuid.toString(), badge, ) addRenderableWidget( @@ -126,7 +138,7 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 120 + visibleRows * 38, + 124 + visibleRows * 26, ), ) } else if (pending.isEmpty()) { @@ -168,9 +180,6 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } - private fun availability(available: Boolean): Component = - Component.translatable(if (available) "options.on" else "options.off") - private fun Ingress.displayName(): String = when (this) { Ingress.CONNECT -> "connect" Ingress.DIRECT_LAN -> "lan" @@ -184,4 +193,8 @@ class ShareStatusScreen( ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } + + private companion object { + const val CONTENT_WIDTH = 310 + } } 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 index 5cc09956c..1653c6127 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Mit Connect teilen", - "connect_share.menu.active": "Connect Share aktiv", - "connect_share.menu.join": "Connect Share beitreten", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", "connect_share.setup.max_guests": "Maximale Gäste", - "connect_share.setup.internet": "Direkte Internetverbindungen erlauben", + "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.start": "Teilen starten", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Mit Freunden teilen", "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": "Connect Share", - "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", - "connect_share.status.copy_invitation": "Einladung kopieren", - "connect_share.status.copy_address": "Vanilla-Adresse kopieren", - "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", + "connect_share.status.title": "Welt ist für Freunde 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": "Beitrittsanfragen", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Erlauben", "connect_share.status.deny": "Ablehnen", "connect_share.status.more": "%s weitere Anfragen", - "connect_share.status.waiting": "Warte auf Freunde…", - "connect_share.status.stop": "Teilen beenden", + "connect_share.status.waiting": "Niemand wartet auf den Beitritt.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpunkt-Identität…", + "connect_share.friends.title": "Freunde", + "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.empty": "Noch keine Freunde. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.save": "Freund speichern", + "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.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.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": "Freund möchte beitreten", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", + "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", 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 index b0a048bbb..7abb70291 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Share with Connect", - "connect_share.menu.active": "Connect Share active", - "connect_share.menu.join": "Join Connect Share", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", "connect_share.setup.max_guests": "Maximum guests", - "connect_share.setup.internet": "Allow direct internet connections", + "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.start": "Start sharing", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Share with friends", "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": "Connect Share", - "connect_share.status.address": "Join address: %s", - "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", - "connect_share.status.copy_invitation": "Copy invitation", - "connect_share.status.copy_address": "Copy vanilla address", - "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", + "connect_share.status.title": "World ready for friends", + "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": "Join requests", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Allow", "connect_share.status.deny": "Deny", "connect_share.status.more": "%s more requests", - "connect_share.status.waiting": "Waiting for friends to join…", - "connect_share.status.stop": "Stop sharing", + "connect_share.status.waiting": "No one is waiting to join.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpoint identity…", + "connect_share.friends.title": "Friends", + "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.empty": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.manage": "Manage", + "connect_share.friends.add": "Add friend", + "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.name": "Friend name", + "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.save": "Save friend", + "connect_share.friends.join_once": "Join once", + "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.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.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": "Friend wants to join", + "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is online. Open Friends 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", 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 index 5f0854e16..3abd08604 100644 --- 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 @@ -17,6 +17,26 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class Fabric262ArtifactTest { + @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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" in language, + ) + } + } + @Test fun `artifact is self contained and isolates networking runtime`() { JarFile(artifact().toFile()).use { jar -> @@ -25,6 +45,10 @@ class Fabric262ArtifactTest { 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/") && 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..90b026376 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.v26_2 + +import io.netty.buffer.Unpooled +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 buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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-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..6e405f5e8 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt @@ -0,0 +1,86 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionIdentity +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, + ), + 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 true + } + + fun consume( + name: String, + uuid: UUID, + ): ApprovedJoinProof? { + val timedProof = approved.remove( + PlayerKey(name.normalized(), uuid), + ) ?: return null + return timedProof.proof.takeIf { + nowMillis() - timedProof.approvedAtMillis <= + PROOF_LIFETIME_MILLIS + } + } + + 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 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/ConnectShareClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt index e4e7a4ca2..50412d4fd 100644 --- 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 @@ -14,6 +14,8 @@ fun interface ConnectShareGuestScreenFactory { data class ConnectShareInstallation( val viewModel: ShareViewModel, val runtime: ConnectShareRuntime, + val friendCardIssuer: FriendCardIssuer, + val approvedJoins: ApprovedJoinTracker, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -22,6 +24,7 @@ object ConnectShareClient { @Volatile private var installation: ConnectShareInstallation? = null private val guestLease = GuestConnectionLease() + private val friendCardConsent = FriendCardExchangeConsent() fun install(value: ConnectShareInstallation) { check(installation == null) { @@ -69,6 +72,19 @@ object ConnectShareClient { fun viewModel(): ShareViewModel = checkNotNull(installation).viewModel + @JvmStatic + fun friendCardIssuer(): FriendCardIssuer = + checkNotNull(installation).friendCardIssuer + + @JvmStatic + fun armFriendCardExchange() { + friendCardConsent.arm() + } + + @JvmStatic + fun consumeFriendCardExchangeConsent(): Boolean = + friendCardConsent.consume() + @JvmStatic fun integratedWorldChanged( worldAvailable: Boolean, @@ -79,6 +95,7 @@ object ConnectShareClient { @JvmStatic fun shutdown() { + friendCardConsent.cancel() guestLease.close() installation?.runtime?.shutdown() } 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 index 9fcc9b143..07d36bf94 100644 --- 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 @@ -33,6 +33,7 @@ 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 runtimeFactory: FabricConnectRuntimeFactory, ) : ConnectShareIngress { @@ -42,10 +43,12 @@ class FabricConnectIngress private constructor( logger: ConnectLogger, platformUtils: FabricPlatformUtils, admission: AdmissionController, + approvedJoins: ApprovedJoinTracker, scope: CoroutineScope, ) : this( dataDirectory = dataDirectory, admission = admission, + approvedJoins = approvedJoins, scope = scope, runtimeFactory = GuiceFabricConnectRuntimeFactory( dataDirectory = dataDirectory, @@ -72,7 +75,11 @@ class FabricConnectIngress private constructor( "Connect endpoint identity changed before sharing started" } - val gate = FabricSessionAdmissionGate(admission, scope) + val gate = FabricSessionAdmissionGate( + admission, + scope, + approvedJoins, + ) val runtime = try { runtimeFactory.start(identity, target, gate) } catch (failure: Throwable) { @@ -98,9 +105,12 @@ class FabricConnectIngress private constructor( admission: AdmissionController, scope: CoroutineScope, runtimeFactory: FabricConnectRuntimeFactory, + approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), ) = FabricConnectIngress( dataDirectory = dataDirectory, admission = admission, + approvedJoins = approvedJoins, scope = scope, runtimeFactory = runtimeFactory, ) 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 index 108ccf6c2..4d5946854 100644 --- 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 @@ -28,6 +28,7 @@ object FabricLoginAdmissionRegistry { connectionId: String, minecraftAuthenticated: Boolean, ingress: Ingress, + directPeerId: String? = null, ): CompletionStage { val gate = installed.get() if (gate == null) { @@ -39,6 +40,7 @@ object FabricLoginAdmissionRegistry { connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, ingress = ingress, + directPeerId = directPeerId, ) } } 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 index ca50fbd30..9c9bdcfbf 100644 --- 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 @@ -25,6 +25,8 @@ import kotlinx.coroutines.launch class FabricSessionAdmissionGate( private val admission: AdmissionController, private val scope: CoroutineScope, + private val approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), ) : SessionAdmissionGate { private val stopped = AtomicBoolean() private val active = ConcurrentHashMap, Job>() @@ -49,7 +51,9 @@ class FabricSessionAdmissionGate( lateinit var job: Job job = scope.launch(start = CoroutineStart.LAZY) { try { - future.complete(admission.request(identity).toCoreDecision()) + val answer = admission.request(identity) + approvedJoins.record(identity, answer) + future.complete(answer.toCoreDecision()) } catch (cancellation: CancellationException) { future.cancel(false) throw cancellation @@ -126,6 +130,8 @@ class FabricSessionAdmissionGate( class FabricLocalLoginAdmission( private val admission: AdmissionController, + private val approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), ) { suspend fun request( name: String, @@ -133,6 +139,7 @@ class FabricLocalLoginAdmission( connectionId: String, minecraftAuthenticated: Boolean, ingress: Ingress = Ingress.CONNECT, + directPeerId: String? = null, ): AdmissionAnswer { val identity = if (minecraftAuthenticated) { AdmissionIdentity.Authenticated( @@ -140,6 +147,7 @@ class FabricLocalLoginAdmission( uuid = uuid, source = AuthSource.MOJANG, ingress = ingress, + directPeerId = directPeerId, ) } else { AdmissionIdentity.UnverifiedOffline( @@ -147,9 +155,12 @@ class FabricLocalLoginAdmission( uuid = uuid, connectionId = connectionId, ingress = ingress, + directPeerId = directPeerId, ) } - return admission.request(identity) + return admission.request(identity).also { answer -> + approvedJoins.record(identity, answer) + } } } @@ -166,6 +177,7 @@ class FabricLocalLoginAdmissionGate( connectionId: String, minecraftAuthenticated: Boolean, ingress: Ingress = Ingress.CONNECT, + directPeerId: String? = null, ): CompletionStage { val future = CompletableFuture() if (stopped.get()) { @@ -183,6 +195,7 @@ class FabricLocalLoginAdmissionGate( connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, ingress = ingress, + directPeerId = directPeerId, ), ) } catch (cancellation: CancellationException) { 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 index efde07026..89fe2f6fd 100644 --- 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 @@ -5,8 +5,10 @@ import com.minekube.connect.identity.EndpointTokenStore import com.minekube.connect.share.ShareCoordinator 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.fabric.ui.ShareViewModel import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -30,7 +32,11 @@ object FabricShareBootstrap { playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, bridgeFactory: - (AdmissionController, CoroutineScope) -> VersionedMinecraftBridge, + ( + AdmissionController, + CoroutineScope, + ApprovedJoinTracker, + ) -> VersionedMinecraftBridge, screens: ConnectShareScreenFactory, guestScreens: ConnectShareGuestScreenFactory, environment: Map = System.getenv(), @@ -38,6 +44,8 @@ object FabricShareBootstrap { httpClient: OkHttpClient = OkHttpClient(), ): ConnectShareInstallation { val viewModelReference = AtomicReference() + val friendStore = FriendStore(dataDirectory) + val approvedJoins = ApprovedJoinTracker() val admission = AdmissionController( scope = scope, connectedCount = { @@ -47,8 +55,26 @@ object FabricShareBootstrap { 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 bridge = bridgeFactory( + admission, + scope, + approvedJoins, ) - val bridge = bridgeFactory(admission, scope) val identityStore = EndpointIdentityStore( directory = dataDirectory, environment = environment, @@ -76,6 +102,7 @@ object FabricShareBootstrap { playerCount = playerCount, ), admission = admission, + approvedJoins = approvedJoins, scope = scope, ) val directIngress = FabricDirectShareIngress( @@ -122,6 +149,10 @@ object FabricShareBootstrap { return ConnectShareInstallation( viewModel = viewModel, runtime = runtime, + friendCardIssuer = FriendCardIssuer(dataDirectory) { + "${identityStore.currentOrCreate().endpoint}.play.minekube.net" + }, + approvedJoins = approvedJoins, screens = screens, guestScreens = guestScreens, ) 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 index 96c3c7405..14affbdcd 100644 --- 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 @@ -9,14 +9,17 @@ 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -96,6 +99,14 @@ class FabricShareBrowser private constructor( ioDispatcher = Dispatchers.IO, ) + constructor(dataDirectory: Path) : this( + node = CoreFabricGuestDirectNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ), + now = Instant::now, + ioDispatcher = Dispatchers.IO, + ) + private val mutableDiscovered = MutableStateFlow>(emptyList()) private val started = AtomicBoolean() @@ -103,6 +114,8 @@ class FabricShareBrowser private constructor( val discovered: StateFlow> = mutableDiscovered.asStateFlow() + val peerId: String + get() = node.peerId() fun start(): Either { if (started.get()) { @@ -178,6 +191,27 @@ class FabricShareBrowser private constructor( } } + suspend fun join( + 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() } + } + friend.connectAddress?.let { + return@withContext GuestJoinTarget.Connect(it).right() + } + GuestJoinFailure.NoRoute.left() + } + override fun close() { if (closed.compareAndSet(false, true)) { node.close() @@ -217,18 +251,44 @@ class FabricShareBrowser private constructor( }?.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 payload = invitation.payload val proxy = node.openProxy( address = address, - shareId = payload.shareId.toString(), - capability = payload.capability, + shareId = shareId, + capability = capability, authMode = authMode, timeout = timeout, ) @@ -251,10 +311,13 @@ class FabricShareBrowser private constructor( 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" } } internal interface FabricGuestDirectNode : AutoCloseable { + fun peerId(): String + fun startDiscovery(listener: DirectP2pDiscoveryListener) fun openProxy( @@ -269,6 +332,8 @@ internal interface FabricGuestDirectNode : AutoCloseable { private class CoreFabricGuestDirectNode( private val node: DirectP2pNode, ) : FabricGuestDirectNode { + override fun peerId(): String = node.peerId() + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { node.startDiscovery(listener) } 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..e49d3c605 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt @@ -0,0 +1,34 @@ +package com.minekube.connect.share.fabric + +class FriendCardExchangeConsent( + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private var armedAtMillis: Long? = null + + @Synchronized + fun arm() { + armedAtMillis = nowMillis() + } + + @Synchronized + fun consume(): Boolean { + val armedAt = armedAtMillis ?: return false + armedAtMillis = null + return nowMillis() - armedAt <= CONSENT_LIFETIME_MILLIS + } + + @Synchronized + fun cancel() { + armedAtMillis = null + } + + 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..d60b0f019 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt @@ -0,0 +1,93 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.flatMap +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 + +class FriendCardReceiver( + private val store: FriendStore, +) { + fun receive( + invitation: String, + displayName: String, + authenticatedMinecraftUuid: UUID?, + now: Instant = Instant.now(), + ): Either = + store.accept(invitation, displayName, now).flatMap { friend -> + store.updatePermissions( + friend.peerId, + friend.permissions.copy( + canJoinAutomatically = true, + ), + ) + }.flatMap { friend -> + authenticatedMinecraftUuid?.let { minecraftUuid -> + store.linkMinecraftIdentity( + friend.peerId, + minecraftUuid, + ) + } ?: Either.Right(friend) + } +} + +class FriendCardIssuer( + private val dataDirectory: Path, + private val connectAddress: suspend () -> String?, +) { + suspend fun issue( + now: Instant = Instant.now(), + ): Either = + Either.catch { + val access = ShareAccessIdentityStore( + dataDirectory, + ).currentOrCreate() + DirectP2pNode( + dataDirectory.resolve(IDENTITY_FILE_NAME), + ).use { node -> + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = access.shareId, + expiresAtEpochMillis = now + .plusSeconds(CARD_LIFETIME_SECONDS) + .toEpochMilli(), + connectAddress = connectAddress(), + peerId = node.peerId(), + internetDirectEnabled = false, + directCandidates = emptyList(), + capability = access.capability, + ) + val publicKey = node.publicKey() + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + publicKey, + ) + ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = publicKey, + signature = node.sign(unsigned), + ), + ) + } + }.mapLeft { + FriendCardIssueFailure + } + + private companion object { + private const val IDENTITY_FILE_NAME = + "share-libp2p-identity.key" + private const val CARD_LIFETIME_SECONDS = 24 * 60 * 60L + } +} 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..c82545422 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt @@ -0,0 +1,88 @@ +package com.minekube.connect.share.fabric + +import arrow.fx.coroutines.parMap +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class RemoteFriendPresence( + val peerId: String, + val displayName: String, + val online: Boolean, + val description: String? = null, + val notifyWhenOnline: Boolean, +) + +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 probe: FriendStatusProbe, +) { + constructor( + store: FriendStore, + probe: FriendStatusProbe = MinecraftStatusProbe(), + ) : this( + friends = store::all, + probe = probe, + ) + + private val mutableState = + MutableStateFlow>(emptyMap()) + + val state: StateFlow> = + mutableState.asStateFlow() + + suspend fun refresh() { + val saved = runCatching(friends) + .getOrDefault(emptyList()) + .take(MAX_PROBED_FRIENDS) + val results = saved.parMap( + context = Dispatchers.IO, + concurrency = MAX_CONCURRENT_PROBES, + ) { friend -> + val result = friend.connectAddress?.let { + probe.probe(it) + } + val presence = result?.getOrNull() + friend.peerId to RemoteFriendPresence( + peerId = friend.peerId, + displayName = friend.displayName, + online = presence != null, + description = presence?.description, + notifyWhenOnline = + friend.permissions.notifyWhenOnline, + ) + } + mutableState.value = results.toMap() + } + + companion object { + internal fun testing( + friends: () -> List, + probe: FriendStatusProbe, + ) = FriendPresenceMonitor(friends, probe) + + 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/MinecraftStatusProbe.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt new file mode 100644 index 000000000..522b18fba --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt @@ -0,0 +1,205 @@ +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 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, +) + +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/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt new file mode 100644 index 000000000..7da48e29d --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt @@ -0,0 +1,163 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +import arrow.core.left +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.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.time.Instant +import java.util.Base64 +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 onlineViaLan: Boolean = false, + val onlineViaConnect: Boolean = false, + val worldName: String? = null, +) + +data class FriendsUiState( + val friends: List = emptyList(), + val safeMessage: String? = null, +) + +class FriendsViewModel( + private val store: FriendStore, +) { + private var discovered: List = emptyList() + private var remotePresence: Map = emptyMap() + private val mutableState = MutableStateFlow(loadInitialState()) + + val state: StateFlow = mutableState.asStateFlow() + + fun accept( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): Boolean = + store.accept(invitationUri, displayName, now).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + false + }, + ifRight = { + refresh() + true + }, + ) + + fun rename(peerId: String, displayName: String) { + store.rename(peerId, displayName).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + refresh() + }, + ) + } + + fun updatePermissions( + peerId: String, + permissions: FriendPermissions, + ) { + store.updatePermissions(peerId, permissions).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + refresh() + }, + ) + } + + fun remove(peerId: String) { + if (store.remove(peerId)) { + refresh() + } + } + + fun updatePresence(discovered: List) { + this.discovered = discovered + refresh() + } + + fun updateRemotePresence( + presence: Map, + ) { + remotePresence = presence + refresh() + } + + suspend fun join( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ): Either { + val friend = savedFriend(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.join(friend, authMode) + } + + internal fun savedFriend(peerId: String): SavedFriend? = + runCatching { + store.all().firstOrNull { it.peerId == peerId } + }.getOrNull() + + private fun refresh() { + mutableState.value = try { + FriendsUiState(friends = store.all().map { it.summary() }) + } catch (_: Exception) { + mutableState.value.copy( + safeMessage = FRIENDS_LOAD_FAILURE, + ) + } + } + + private fun loadInitialState(): FriendsUiState = try { + FriendsUiState(friends = store.all().map { it.summary() }) + } catch (_: Exception) { + FriendsUiState(safeMessage = FRIENDS_LOAD_FAILURE) + } + + private fun update(transform: FriendsUiState.() -> FriendsUiState) { + mutableState.value = mutableState.value.transform() + } + + private fun SavedFriend.summary(): FriendSummary { + val presence = discovered.firstOrNull { + val invitation = it.invitation + invitation.payload.peerId == peerId && + invitation.payload.shareId == shareId && + Base64.getEncoder().encodeToString(invitation.publicKey) == + publicKeyBase64 + } + val remote = remotePresence[peerId] + ?.takeIf { it.online } + return FriendSummary( + peerId = peerId, + displayName = displayName, + connectAvailable = connectAddress != null, + permissions = permissions, + onlineViaLan = presence != null, + onlineViaConnect = remote != null, + worldName = presence?.displayName ?: remote?.description, + ) + } + + private companion object { + const val FRIENDS_LOAD_FAILURE = + "Saved Connect Share friends could not be loaded" + } +} 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..5802dee98 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt @@ -0,0 +1,71 @@ +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 java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ApprovedJoinTrackerTest { + private var nowMillis = 1_000L + private val tracker = ApprovedJoinTracker { nowMillis } + + @Test + fun `approved authenticated identity can be consumed once`() { + tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + + assertEquals(true, tracker.hasProof("Robin", PLAYER_UUID)) + assertEquals( + PLAYER_UUID, + tracker.consume("Robin", PLAYER_UUID) + ?.authenticatedMinecraftUuid, + ) + assertNull(tracker.consume("Robin", PLAYER_UUID)) + } + + @Test + fun `approved offline identity proves pairing without trusting its uuid`() { + tracker.record(OFFLINE, AdmissionAnswer.ALLOW) + + val proof = tracker.consume("Robin", PLAYER_UUID) + + 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)) + } + + @Test + fun `authentication proof expires before an unrelated later join`() { + tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + nowMillis += 121_000 + + assertNull(tracker.consume("Robin", PLAYER_UUID)) + } + + 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/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt index 885a75a03..7324fef45 100644 --- 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 @@ -26,7 +26,12 @@ class FabricSessionAdmissionGateTest { @Test fun `Connect authenticated profile waits for host approval`() = runTest { val admission = admission() - val gate = FabricSessionAdmissionGate(admission, backgroundScope) + val approvedJoins = ApprovedJoinTracker() + val gate = FabricSessionAdmissionGate( + admission, + backgroundScope, + approvedJoins, + ) val result = gate.request(proposal(passthrough = false)).toCompletableFuture() runCurrent() @@ -39,6 +44,11 @@ class FabricSessionAdmissionGateTest { admission.answer(pending.requestId, allow = true) runCurrent() assertTrue(result.getNow(null).isAllowed) + assertEquals( + PLAYER_UUID, + approvedJoins.consume("Alex", PLAYER_UUID) + ?.authenticatedMinecraftUuid, + ) } @Test @@ -112,13 +122,18 @@ class FabricSessionAdmissionGateTest { @Test fun `local login maps authenticated and offline identities separately`() = runTest { val admission = admission() - val local = FabricLocalLoginAdmission(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() @@ -126,8 +141,17 @@ class FabricSessionAdmissionGateTest { 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()) + assertEquals( + PLAYER_UUID, + approvedJoins.consume("Alex", PLAYER_UUID) + ?.authenticatedMinecraftUuid, + ) val offline = async { local.request( @@ -135,6 +159,7 @@ class FabricSessionAdmissionGateTest { uuid = PLAYER_UUID, connectionId = "connection-offline", minecraftAuthenticated = false, + directPeerId = "12D3KooWOffline", ) } runCurrent() @@ -143,6 +168,7 @@ class FabricSessionAdmissionGateTest { ) 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()) } 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 index 7905adc98..3b128beb5 100644 --- 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 @@ -5,16 +5,19 @@ 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.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -22,8 +25,25 @@ 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 { @@ -96,6 +116,59 @@ class FabricShareBrowserTest { 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 ignores LAN metadata signed by a different identity`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + 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, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() + } + @Test fun `pasted invitation ignores discovery with a different peer`() = runTest { val node = FakeGuestNode() @@ -225,6 +298,22 @@ class FabricShareBrowserTest { ) } + 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, + displayName = "Robin", + ) + } + private fun lanAddress(peerId: String) = "/ip4/192.168.1.20/tcp/4001/p2p/$peerId" @@ -237,6 +326,8 @@ class FabricShareBrowserTest { private var listener: DirectP2pDiscoveryListener? = null val openedAddresses = mutableListOf() + override fun peerId(): String = "12D3KooWGuest" + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { this.listener = listener } 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..e9194b659 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt @@ -0,0 +1,62 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FriendCardExchangeConsentTest { + private var nowMillis = 1_000L + private val consent = FriendCardExchangeConsent { nowMillis } + + @Test + fun `armed Share join allows exactly one reciprocal card request`() { + consent.arm() + + assertTrue(consent.consume()) + assertFalse(consent.consume()) + } + + @Test + fun `stale Share join cannot leak a card to a later server`() { + consent.arm() + nowMillis += 121_000 + + assertFalse(consent.consume()) + } + + @Test + fun `cancel removes pending consent`() { + consent.arm() + consent.cancel() + + assertFalse(consent.consume()) + } + + @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, + ), + ) + } +} 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..5aea63f9b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt @@ -0,0 +1,115 @@ +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" }, + ) + + 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, + ) + 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, + 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 `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, + ) + } + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + } +} 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..64fe60cdd --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt @@ -0,0 +1,92 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +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.assertTrue +import kotlinx.coroutines.test.runTest + +class FriendPresenceMonitorTest { + @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) }, + probe = FriendStatusProbe { address -> + if (address.startsWith("online")) { + Either.Right(ServerPresence("Robin's World")) + } else { + Either.Left(StatusProbeError.EndpointOffline) + } + }, + ) + + 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 `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/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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt new file mode 100644 index 000000000..b86d2ca8b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt @@ -0,0 +1,250 @@ +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.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricGuestDirectNode +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.RemoteFriendPresence +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 com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendPermissions +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.assertTrue +import kotlin.test.assertIs +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 `accepting one link exposes a safe saved friend summary`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + assertTrue(viewModel.accept(signedLink(), "Robin", NOW)) + + val friend = viewModel.state.value.friends.single() + assertEquals(PEER_ID, friend.peerId) + assertEquals("Robin", friend.displayName) + assertTrue(friend.connectAvailable) + assertTrue(friend.permissions.notifyWhenOnline) + assertFalse(viewModel.state.value.toString().contains(CAPABILITY)) + assertEquals(null, viewModel.state.value.safeMessage) + } + + @Test + fun `invalid friend link stays on the add flow with a useful message`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + val accepted = viewModel.accept( + "minekube://share/not-a-valid-link", + "Robin", + NOW, + ) + + assertFalse(accepted) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(viewModel.state.value.safeMessage?.isNotBlank() == true) + } + + @Test + fun `saved friend can be renamed configured and removed`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(signedLink(), "Robin", NOW) + + 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) + + viewModel.remove(PEER_ID) + + 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 viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(link, "Robin", NOW) + + viewModel.updatePresence( + listOf( + DiscoveredLanShare( + displayName = "Robin's New World", + invitationUri = link, + invitation = invitation, + lanAddress = + "/ip4/192.168.1.25/tcp/4001/p2p/$PEER_ID", + ), + ), + ) + + val online = viewModel.state.value.friends.single() + assertTrue(online.onlineViaLan) + assertEquals("Robin's New World", online.worldName) + + viewModel.updatePresence(emptyList()) + + assertFalse(viewModel.state.value.friends.single().onlineViaLan) + } + + @Test + fun `Connect presence marks a saved friend online across networks`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(signedLink(), "Robin", NOW) + + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Robin's Remote World", + notifyWhenOnline = true, + ), + ), + ) + + val online = viewModel.state.value.friends.single() + assertTrue(online.onlineViaConnect) + assertEquals("Robin's Remote World", online.worldName) + } + + @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 viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(link, "Robin", NOW) + 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() + } + + private fun signedLink(): 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, + ) + 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" + } +} From 5d8ae0f548f952de02ba059e94d453fd81de6555 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 02:32:16 +0200 Subject: [PATCH 032/188] fix(share): repair friend removal and link access --- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 134 ++++++++++++++---- .../assets/connect-share/lang/de_de.json | 6 + .../assets/connect-share/lang/en_us.json | 6 + .../v1_21_11/Fabric12111ArtifactTest.kt | 23 +++ .../share/fabric/v26_2/ShareJoinScreen.kt | 134 ++++++++++++++---- .../assets/connect-share/lang/de_de.json | 6 + .../assets/connect-share/lang/en_us.json | 6 + .../fabric/v26_2/Fabric262ArtifactTest.kt | 25 ++++ .../share/fabric/ui/FriendsViewModel.kt | 20 ++- .../share/fabric/ui/FriendsViewModelTest.kt | 4 +- 10 files changed, 308 insertions(+), 56 deletions(-) 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 index be999fb51..365b9afe7 100644 --- 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 @@ -10,17 +10,18 @@ import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions 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.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.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -52,6 +53,8 @@ class ShareJoinScreen( private var joiningPeerId: String? = null private var reciprocalPairing = false private var transferred = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE override fun init() { if (scope == null) { @@ -88,6 +91,11 @@ class ShareJoinScreen( } override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } when (mode) { Mode.FRIENDS -> minecraft.setScreen(parent) Mode.ADD, @@ -159,11 +167,26 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 54) + centered(Component.literal(message), height - 76) .setMaxWidth(CONTENT_WIDTH), ) } } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.add"), @@ -171,11 +194,11 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) } @@ -316,6 +339,10 @@ class ShareJoinScreen( rebuildWidgets() return } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } addRenderableWidget( centered( Component.translatable( @@ -396,25 +423,8 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.remove"), ) { - minecraft.setScreen( - ConfirmScreen( - { confirmed -> - if (confirmed) { - friends.remove(friend.peerId) - mode = Mode.FRIENDS - selectedPeerId = null - } - minecraft.setScreen(this) - }, - Component.translatable( - "connect_share.friends.remove_confirm.title", - friend.displayName, - ), - Component.translatable( - "connect_share.friends.remove_confirm.message", - ), - ), - ) + removeConfirmation = true + rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -425,6 +435,70 @@ class ShareJoinScreen( refresh() } + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + friends.remove(friend.peerId) + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + nameValue = "" + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 5, height - 28, 150, 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 @@ -518,7 +592,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() - primaryButton?.active = !joining && + primaryButton?.active = + !joining && friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -595,6 +670,15 @@ class ShareJoinScreen( MANAGE, } + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.copy_my_link"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 const val MAX_VISIBLE_FRIENDS = 5 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 index 1653c6127..bf89595ad 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Beitreten", "connect_share.friends.title": "Freunde", "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -62,6 +67,7 @@ "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.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", 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 index 7abb70291..bde5bf82b 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends yet. Add a friend's link once; their future worlds appear here.", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -62,6 +67,7 @@ "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.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", 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 index bba0ddf50..ad58bb5d9 100644 --- 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 @@ -34,6 +34,29 @@ class Fabric12111ArtifactTest { "\"connect_share.status.copy_invitation\": " + "\"Copy friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val screen = jar.getJarEntry( + "com/minekube/connect/share/fabric/v1_21_11/" + + "ShareJoinScreen.class", + ) + assertNotNull(screen) + val bytecode = jar.getInputStream(screen).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + + assertFalse("net/minecraft/class_410" in bytecode) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) } } 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 index b0ee9b699..ffdc17882 100644 --- 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 @@ -10,17 +10,18 @@ import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions 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.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.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -52,6 +53,8 @@ class ShareJoinScreen( private var joiningPeerId: String? = null private var reciprocalPairing = false private var transferred = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE override fun init() { if (scope == null) { @@ -88,6 +91,11 @@ class ShareJoinScreen( } override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } when (mode) { Mode.FRIENDS -> minecraft.gui.setScreen(parent) Mode.ADD, @@ -159,11 +167,26 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 54) + centered(Component.literal(message), height - 76) .setMaxWidth(CONTENT_WIDTH), ) } } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.add"), @@ -171,11 +194,11 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) } @@ -316,6 +339,10 @@ class ShareJoinScreen( rebuildWidgets() return } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } addRenderableWidget( centered( Component.translatable( @@ -396,25 +423,8 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.remove"), ) { - minecraft.gui.setScreen( - ConfirmScreen( - { confirmed -> - if (confirmed) { - friends.remove(friend.peerId) - mode = Mode.FRIENDS - selectedPeerId = null - } - minecraft.gui.setScreen(this) - }, - Component.translatable( - "connect_share.friends.remove_confirm.title", - friend.displayName, - ), - Component.translatable( - "connect_share.friends.remove_confirm.message", - ), - ), - ) + removeConfirmation = true + rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -425,6 +435,70 @@ class ShareJoinScreen( refresh() } + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + friends.remove(friend.peerId) + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + nameValue = "" + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 5, height - 28, 150, 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 @@ -517,7 +591,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() - primaryButton?.active = !joining && + primaryButton?.active = + !joining && friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -594,6 +669,15 @@ class ShareJoinScreen( MANAGE, } + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.copy_my_link"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 const val MAX_VISIBLE_FRIENDS = 5 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 index 1653c6127..bf89595ad 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Beitreten", "connect_share.friends.title": "Freunde", "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -62,6 +67,7 @@ "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.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", 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 index 7abb70291..bde5bf82b 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends yet. Add a friend's link once; their future worlds appear here.", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -62,6 +67,7 @@ "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.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", 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 index 3abd08604..8b9b2b468 100644 --- 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 @@ -34,6 +34,31 @@ class Fabric262ArtifactTest { "\"connect_share.status.copy_invitation\": " + "\"Copy friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val screen = jar.getJarEntry( + "com/minekube/connect/share/fabric/v26_2/" + + "ShareJoinScreen.class", + ) + assertNotNull(screen) + val bytecode = jar.getInputStream(screen).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + + assertFalse( + "net/minecraft/client/gui/screens/ConfirmScreen" in bytecode, + ) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) } } 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 index 7da48e29d..7de59d74e 100644 --- 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 @@ -82,11 +82,19 @@ class FriendsViewModel( ) } - fun remove(peerId: String) { - if (store.remove(peerId)) { - refresh() - } - } + fun remove(peerId: String): Boolean = + Either.catch { + store.remove(peerId) + }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_REMOVE_FAILURE) } + false + }, + ifRight = { removed -> + refresh() + removed + }, + ) fun updatePresence(discovered: List) { this.discovered = discovered @@ -159,5 +167,7 @@ class FriendsViewModel( private companion object { const val FRIENDS_LOAD_FAILURE = "Saved Connect Share friends could not be loaded" + const val FRIEND_REMOVE_FAILURE = + "This Connect Share friend could not be removed" } } 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 index b86d2ca8b..80f572438 100644 --- 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 @@ -84,9 +84,11 @@ class FriendsViewModelTest { assertFalse(managed.permissions.notifyWhenOnline) assertTrue(managed.permissions.canJoinAutomatically) - viewModel.remove(PEER_ID) + assertTrue(viewModel.remove(PEER_ID)) assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(FriendStore(tempDir).all().isEmpty()) + assertFalse(viewModel.remove(PEER_ID)) } @Test From f57b1fdef67629692d8efbb3a56630ce4858e20c Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 02:54:12 +0200 Subject: [PATCH 033/188] feat(share): add mutual friend requests --- .../connect/share/friend/FriendStore.kt | 106 ++++++++++++-- .../connect/share/friend/FriendStoreTest.kt | 100 +++++++++++++ .../fabric/v1_21_11/FriendCardNetworking.kt | 9 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 135 +++++++++++++----- .../assets/connect-share/lang/de_de.json | 10 +- .../assets/connect-share/lang/en_us.json | 10 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 38 +++++ .../fabric/v26_2/FriendCardNetworking.kt | 9 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 135 +++++++++++++----- .../assets/connect-share/lang/de_de.json | 10 +- .../assets/connect-share/lang/en_us.json | 10 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 38 +++++ .../share/fabric/ConnectShareClient.kt | 6 +- .../share/fabric/FriendCardExchangeConsent.kt | 32 +++-- .../connect/share/fabric/FriendCardIssuer.kt | 5 + .../share/fabric/ui/FriendsViewModel.kt | 42 +++++- .../fabric/FriendCardExchangeConsentTest.kt | 22 +-- .../share/fabric/FriendCardIssuerTest.kt | 27 ++++ .../share/fabric/ui/FriendsViewModelTest.kt | 98 ++++++++++--- 19 files changed, 704 insertions(+), 138 deletions(-) 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 index bffa267aa..ea886d027 100644 --- 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 @@ -32,6 +32,11 @@ data class FriendPermissions( val canJoinAutomatically: Boolean = false, ) +enum class FriendRelationshipStatus { + PENDING_INCOMING, + CONFIRMED, +} + data class SavedFriend( val peerId: String, val publicKeyBase64: String, @@ -41,12 +46,15 @@ data class SavedFriend( 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, displayName=$displayName, " + - "minecraftUuid=$minecraftUuid, permissions=$permissions)" + "minecraftUuid=$minecraftUuid, permissions=$permissions, " + + "relationshipStatus=$relationshipStatus)" } sealed interface FriendStoreError { @@ -76,13 +84,59 @@ class FriendStore( private val directory: Path, ) { @Synchronized - fun all(): List = read() + fun all(): List = + read().filter { + it.relationshipStatus == FriendRelationshipStatus.CONFIRMED + } + + @Synchronized + fun pendingRequests(): List = + read().filter { + it.relationshipStatus == + FriendRelationshipStatus.PENDING_INCOMING + } @Synchronized fun accept( invitationUri: String, displayName: String, now: Instant = Instant.now(), + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + now = now, + ) + + @Synchronized + fun receiveRequest( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = + FriendRelationshipStatus.PENDING_INCOMING, + now = now, + ) + + @Synchronized + fun confirmPending( + peerId: String, + ): Either = update(peerId) { friend -> + friend.copy( + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + ) + } + + private fun storeInvitation( + invitationUri: String, + displayName: String, + relationshipStatus: FriendRelationshipStatus, + now: Instant, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) .mapLeft(FriendStoreError::InvalidInvitation) @@ -100,6 +154,13 @@ class FriendStore( 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, @@ -109,6 +170,7 @@ class FriendStore( displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = existing?.permissions ?: FriendPermissions(), + relationshipStatus = effectiveRelationshipStatus, ) write( current.filterNot { it.peerId == friend.peerId } + friend, @@ -222,6 +284,21 @@ class FriendStore( 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"), + canJoinAutomatically = + permissions.requiredBoolean("canJoinAutomatically"), + ) + val relationshipStatus = json + .optionalString("relationshipStatus") + ?.let(FriendRelationshipStatus::valueOf) + ?: legacyRelationshipStatus( + minecraftUuid = minecraftUuid, + permissions = parsedPermissions, + ) return SavedFriend( peerId = peerId, publicKeyBase64 = publicKey, @@ -230,12 +307,8 @@ class FriendStore( connectAddress = connectAddress, displayName = displayName, minecraftUuid = minecraftUuid, - permissions = FriendPermissions( - notifyWhenOnline = permissions.requiredBoolean("notifyWhenOnline"), - canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), - canJoinAutomatically = - permissions.requiredBoolean("canJoinAutomatically"), - ), + permissions = parsedPermissions, + relationshipStatus = relationshipStatus, ) } @@ -258,6 +331,10 @@ class FriendStore( friend.minecraftUuid?.let { addProperty("minecraftUuid", it.toString()) } + addProperty( + "relationshipStatus", + friend.relationshipStatus.name, + ) add( "permissions", JsonObject().apply { @@ -346,6 +423,19 @@ class FriendStore( 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_INCOMING + } + private fun isValidCapability(value: String): Boolean = value.length in 16..512 && value.none(Char::isWhitespace) 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 index 106ea2738..d5dc67bf5 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,90 @@ class FriendStoreTest { @TempDir lateinit var tempDir: Path + @Test + fun `receiving a signed link stores only a pending request`() { + val store = FriendStore(tempDir) + + val request = assertIs>( + store.receiveRequest(signedLink(), "Robin", NOW), + ).value + + assertEquals( + FriendRelationshipStatus.PENDING_INCOMING, + request.relationshipStatus, + ) + assertTrue(store.all().isEmpty()) + assertEquals( + listOf(request), + FriendStore(tempDir).pendingRequests(), + ) + } + + @Test + fun `confirming a pending request promotes it across restarts`() { + val store = FriendStore(tempDir) + store.receiveRequest(signedLink(), "Robin", NOW) + + val confirmed = assertIs>( + store.confirmPending(PEER_ID), + ).value + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + confirmed.relationshipStatus, + ) + assertEquals( + listOf(confirmed), + FriendStore(tempDir).all(), + ) + assertTrue(FriendStore(tempDir).pendingRequests().isEmpty()) + } + + @Test + fun `receiving the same link never demotes a confirmed friend`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + val received = assertIs>( + store.receiveRequest(signedLink(), "Robin", NOW), + ).value + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + received.relationshipStatus, + ) + assertEquals(PEER_ID, store.all().single().peerId) + assertTrue(store.pendingRequests().isEmpty()) + } + + @Test + fun `legacy unverified relationships migrate to pending`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + stripRelationshipStatus() + + val migrated = FriendStore(tempDir) + + assertTrue(migrated.all().isEmpty()) + assertEquals(PEER_ID, migrated.pendingRequests().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.pendingRequests().isEmpty()) + } + @Test fun `accepting one signed link saves a friend across restarts`() { val link = signedLink() @@ -36,6 +121,10 @@ class FriendStoreTest { 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) @@ -146,6 +235,17 @@ class FriendStoreTest { ) } + 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 companion object { val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") val SHARE_ID: UUID = 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 index a781e05ba..ad8d412c3 100644 --- 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 @@ -67,9 +67,9 @@ object FriendCardNetworking { ClientPlayNetworking.registerGlobalReceiver( FriendCardRequestPayload.TYPE, ) { _, context -> - if (!ConnectShareClient.consumeFriendCardExchangeConsent()) { - return@registerGlobalReceiver - } + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver val client = context.client() scope.launch(Dispatchers.IO) { issuer.issue().getOrNull()?.let { invitation -> @@ -83,6 +83,9 @@ object FriendCardNetworking { ClientPlayNetworking.send( FriendCardPayload(invitation), ) + scope.launch(Dispatchers.IO) { + receiver.confirmPending(exchange.peerId) + } } } } 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 index 365b9afe7..06cd8441c 100644 --- 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 @@ -132,36 +132,74 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) - val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) - if (saved.isEmpty()) { + val state = friends.state.value + val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val saved = state.friends.take( + MAX_VISIBLE_RELATIONSHIPS - pending.size, + ) + if (pending.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), 82, ).setMaxWidth(CONTENT_WIDTH), ) - } else { - saved.forEachIndexed { index, friend -> - val y = 58 + index * 26 - addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null - rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), - ) - } + } + pending.forEachIndexed { index, request -> + val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.pending_request", + request.displayName, + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.accept_request", + ), + ) { + joinPending(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.decline_request", + ), + ) { + friends.remove(request.peerId) + rebuildWidgets() + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + saved.forEachIndexed { index, friend -> + val y = 58 + (pending.size + index) * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) } safeMessage().let { message -> @@ -302,12 +340,11 @@ class ShareJoinScreen( } primaryButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.save"), + Component.translatable( + "connect_share.friends.save_request", + ), ) { - if (friends.accept(invitationValue, nameValue)) { - scope?.launch { - remotePresence.refresh() - } + if (friends.receiveRequest(invitationValue, nameValue)) { invitationValue = "" nameValue = "" mode = Mode.FRIENDS @@ -518,6 +555,25 @@ class ShareJoinScreen( } } + private fun joinPending(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.joinPending( + peerId = peerId, + browser = browser, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -564,21 +620,30 @@ class ShareJoinScreen( } else { browser.close() } - val joiningFriend = friends.state.value.friends.firstOrNull { + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val pendingRequest = state.pendingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( - joiningFriend?.displayName ?: "Connect Share", + joiningFriend?.displayName + ?: pendingRequest?.displayName + ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds, + joiningFriend?.permissions?.canSeeMyWorlds + ?: (pendingRequest != null), ) - if (exchangeFriendCard) { - ConnectShareClient.armFriendCardExchange() + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) } ConnectScreen.startConnecting( parent, @@ -681,7 +746,7 @@ class ShareJoinScreen( private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_FRIENDS = 5 + const val MAX_VISIBLE_RELATIONSHIPS = 5 const val CONTENT_WIDTH = 310 } } 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 index bf89595ad..43091293b 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.empty": "Noch keine Freunde oder Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", + "connect_share.friends.pending_request": "Anfrage von %s", + "connect_share.friends.accept_request": "Annehmen", + "connect_share.friends.decline_request": "Ablehnen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", "connect_share.friends.name": "Name des Freundes", "connect_share.friends.name_hint": "Wie du die Person kennst", "connect_share.friends.save": "Freund speichern", + "connect_share.friends.save_request": "Anfrage speichern", "connect_share.friends.join_once": "Einmal beitreten", "connect_share.friends.manage_title": "%s verwalten", "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", 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 index bde5bf82b..aa60a7bf6 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.empty": "No friends or requests yet. Add a friend's link to send a request.", + "connect_share.friends.pending_request": "Request from %s", + "connect_share.friends.accept_request": "Accept", + "connect_share.friends.decline_request": "Decline", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", "connect_share.friends.name": "Friend name", "connect_share.friends.name_hint": "How you know them", "connect_share.friends.save": "Save friend", + "connect_share.friends.save_request": "Save request", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", 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 index ad58bb5d9..5e878dc3d 100644 --- 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 @@ -38,6 +38,22 @@ class Fabric12111ArtifactTest { "\"connect_share.friends.copy_my_link\": " + "\"Copy my friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.save_request\": " + + "\"Save request\"" in language, + ) + assertTrue( + "\"connect_share.friends.pending_request\": " + + "\"Request from %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.accept_request\": \"Accept\"" in + language, + ) + assertTrue( + "\"connect_share.friends.decline_request\": \"Decline\"" in + language, + ) } } @@ -57,6 +73,28 @@ class Fabric12111ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) + assertTrue("receiveRequest" in bytecode) + assertTrue("joinPending" in bytecode) + } + } + + @Test + fun `approved card exchange promotes a pending 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("confirmPending" in bytecode) } } 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 index f5b6ef4e9..a8702303c 100644 --- 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 @@ -67,9 +67,9 @@ object FriendCardNetworking { ClientPlayNetworking.registerGlobalReceiver( FriendCardRequestPayload.TYPE, ) { _, context -> - if (!ConnectShareClient.consumeFriendCardExchangeConsent()) { - return@registerGlobalReceiver - } + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver val client = context.client() scope.launch(Dispatchers.IO) { issuer.issue().getOrNull()?.let { invitation -> @@ -83,6 +83,9 @@ object FriendCardNetworking { ClientPlayNetworking.send( FriendCardPayload(invitation), ) + scope.launch(Dispatchers.IO) { + receiver.confirmPending(exchange.peerId) + } } } } 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 index ffdc17882..8382a9f5c 100644 --- 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 @@ -132,36 +132,74 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) - val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) - if (saved.isEmpty()) { + val state = friends.state.value + val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val saved = state.friends.take( + MAX_VISIBLE_RELATIONSHIPS - pending.size, + ) + if (pending.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), 82, ).setMaxWidth(CONTENT_WIDTH), ) - } else { - saved.forEachIndexed { index, friend -> - val y = 58 + index * 26 - addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null - rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), - ) - } + } + pending.forEachIndexed { index, request -> + val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.pending_request", + request.displayName, + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.accept_request", + ), + ) { + joinPending(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.decline_request", + ), + ) { + friends.remove(request.peerId) + rebuildWidgets() + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + saved.forEachIndexed { index, friend -> + val y = 58 + (pending.size + index) * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) } safeMessage().let { message -> @@ -302,12 +340,11 @@ class ShareJoinScreen( } primaryButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.save"), + Component.translatable( + "connect_share.friends.save_request", + ), ) { - if (friends.accept(invitationValue, nameValue)) { - scope?.launch { - remotePresence.refresh() - } + if (friends.receiveRequest(invitationValue, nameValue)) { invitationValue = "" nameValue = "" mode = Mode.FRIENDS @@ -518,6 +555,25 @@ class ShareJoinScreen( } } + private fun joinPending(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.joinPending( + peerId = peerId, + browser = browser, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -563,21 +619,30 @@ class ShareJoinScreen( } else { browser.close() } - val joiningFriend = friends.state.value.friends.firstOrNull { + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val pendingRequest = state.pendingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( - joiningFriend?.displayName ?: "Connect Share", + joiningFriend?.displayName + ?: pendingRequest?.displayName + ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds, + joiningFriend?.permissions?.canSeeMyWorlds + ?: (pendingRequest != null), ) - if (exchangeFriendCard) { - ConnectShareClient.armFriendCardExchange() + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) } ConnectScreen.startConnecting( parent, @@ -680,7 +745,7 @@ class ShareJoinScreen( private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_FRIENDS = 5 + const val MAX_VISIBLE_RELATIONSHIPS = 5 const val CONTENT_WIDTH = 310 } } 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 index bf89595ad..43091293b 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.empty": "Noch keine Freunde oder Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", + "connect_share.friends.pending_request": "Anfrage von %s", + "connect_share.friends.accept_request": "Annehmen", + "connect_share.friends.decline_request": "Ablehnen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", "connect_share.friends.name": "Name des Freundes", "connect_share.friends.name_hint": "Wie du die Person kennst", "connect_share.friends.save": "Freund speichern", + "connect_share.friends.save_request": "Anfrage speichern", "connect_share.friends.join_once": "Einmal beitreten", "connect_share.friends.manage_title": "%s verwalten", "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", 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 index bde5bf82b..aa60a7bf6 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.empty": "No friends or requests yet. Add a friend's link to send a request.", + "connect_share.friends.pending_request": "Request from %s", + "connect_share.friends.accept_request": "Accept", + "connect_share.friends.decline_request": "Decline", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", "connect_share.friends.name": "Friend name", "connect_share.friends.name_hint": "How you know them", "connect_share.friends.save": "Save friend", + "connect_share.friends.save_request": "Save request", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", 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 index 8b9b2b468..c0ff26576 100644 --- 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 @@ -38,6 +38,22 @@ class Fabric262ArtifactTest { "\"connect_share.friends.copy_my_link\": " + "\"Copy my friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.save_request\": " + + "\"Save request\"" in language, + ) + assertTrue( + "\"connect_share.friends.pending_request\": " + + "\"Request from %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.accept_request\": \"Accept\"" in + language, + ) + assertTrue( + "\"connect_share.friends.decline_request\": \"Decline\"" in + language, + ) } } @@ -59,6 +75,28 @@ class Fabric262ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) + assertTrue("receiveRequest" in bytecode) + assertTrue("joinPending" in bytecode) + } + } + + @Test + fun `approved card exchange promotes a pending 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("confirmPending" in bytecode) } } 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 index 50412d4fd..b85069c23 100644 --- 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 @@ -77,12 +77,12 @@ object ConnectShareClient { checkNotNull(installation).friendCardIssuer @JvmStatic - fun armFriendCardExchange() { - friendCardConsent.arm() + fun armFriendCardExchange(peerId: String) { + friendCardConsent.arm(peerId) } @JvmStatic - fun consumeFriendCardExchangeConsent(): Boolean = + fun consumeFriendCardExchangeConsent(): FriendCardExchangeProof? = friendCardConsent.consume() @JvmStatic 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 index e49d3c605..8e9eb9da3 100644 --- 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 @@ -1,27 +1,43 @@ package com.minekube.connect.share.fabric +data class FriendCardExchangeProof( + val peerId: String, +) + class FriendCardExchangeConsent( private val nowMillis: () -> Long = System::currentTimeMillis, ) { - private var armedAtMillis: Long? = null + private var armed: TimedExchange? = null @Synchronized - fun arm() { - armedAtMillis = nowMillis() + fun arm(peerId: String) { + require(peerId.isNotBlank()) + armed = TimedExchange( + proof = FriendCardExchangeProof(peerId), + armedAtMillis = nowMillis(), + ) } @Synchronized - fun consume(): Boolean { - val armedAt = armedAtMillis ?: return false - armedAtMillis = null - return nowMillis() - armedAt <= CONSENT_LIFETIME_MILLIS + fun consume(): FriendCardExchangeProof? { + val exchange = armed ?: return null + armed = null + return exchange.proof.takeIf { + nowMillis() - exchange.armedAtMillis <= + CONSENT_LIFETIME_MILLIS + } } @Synchronized fun cancel() { - armedAtMillis = null + armed = null } + private data class TimedExchange( + val proof: FriendCardExchangeProof, + val armedAtMillis: Long, + ) + companion object { const val CONSENT_LIFETIME_MILLIS = 120_000L 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 index d60b0f019..661872f3c 100644 --- 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 @@ -19,6 +19,11 @@ data object FriendCardIssueFailure class FriendCardReceiver( private val store: FriendStore, ) { + fun confirmPending( + peerId: String, + ): Either = + store.confirmPending(peerId) + fun receive( invitation: String, displayName: String, 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 index 7de59d74e..943bdf180 100644 --- 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 @@ -27,8 +27,14 @@ data class FriendSummary( val worldName: String? = null, ) +data class PendingFriendSummary( + val peerId: String, + val displayName: String, +) + data class FriendsUiState( val friends: List = emptyList(), + val pendingRequests: List = emptyList(), val safeMessage: String? = null, ) @@ -41,12 +47,12 @@ class FriendsViewModel( val state: StateFlow = mutableState.asStateFlow() - fun accept( + fun receiveRequest( invitationUri: String, displayName: String, now: Instant = Instant.now(), ): Boolean = - store.accept(invitationUri, displayName, now).fold( + store.receiveRequest(invitationUri, displayName, now).fold( ifLeft = { failure -> update { copy(safeMessage = failure.safeMessage) } false @@ -118,14 +124,31 @@ class FriendsViewModel( return browser.join(friend, authMode) } + suspend fun joinPending( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ): Either { + val request = pendingRequest(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.join(request, authMode) + } + internal fun savedFriend(peerId: String): SavedFriend? = runCatching { store.all().firstOrNull { it.peerId == peerId } }.getOrNull() + internal fun pendingRequest(peerId: String): SavedFriend? = + runCatching { + store.pendingRequests().firstOrNull { + it.peerId == peerId + } + }.getOrNull() + private fun refresh() { mutableState.value = try { - FriendsUiState(friends = store.all().map { it.summary() }) + currentState() } catch (_: Exception) { mutableState.value.copy( safeMessage = FRIENDS_LOAD_FAILURE, @@ -134,11 +157,22 @@ class FriendsViewModel( } private fun loadInitialState(): FriendsUiState = try { - FriendsUiState(friends = store.all().map { it.summary() }) + currentState() } catch (_: Exception) { FriendsUiState(safeMessage = FRIENDS_LOAD_FAILURE) } + private fun currentState(): FriendsUiState = + FriendsUiState( + friends = store.all().map { it.summary() }, + pendingRequests = store.pendingRequests().map { + PendingFriendSummary( + peerId = it.peerId, + displayName = it.displayName, + ) + }, + ) + private fun update(transform: FriendsUiState.() -> FriendsUiState) { mutableState.value = mutableState.value.transform() } 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 index e9194b659..d177e8b90 100644 --- 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 @@ -1,7 +1,9 @@ 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 class FriendCardExchangeConsentTest { @@ -9,27 +11,27 @@ class FriendCardExchangeConsentTest { private val consent = FriendCardExchangeConsent { nowMillis } @Test - fun `armed Share join allows exactly one reciprocal card request`() { - consent.arm() + fun `armed Share join returns its peer exactly once`() { + consent.arm(PEER_ID) - assertTrue(consent.consume()) - assertFalse(consent.consume()) + assertEquals(PEER_ID, consent.consume()?.peerId) + assertNull(consent.consume()) } @Test fun `stale Share join cannot leak a card to a later server`() { - consent.arm() + consent.arm(PEER_ID) nowMillis += 121_000 - assertFalse(consent.consume()) + assertNull(consent.consume()) } @Test fun `cancel removes pending consent`() { - consent.arm() + consent.arm(PEER_ID) consent.cancel() - assertFalse(consent.consume()) + assertNull(consent.consume()) } @Test @@ -59,4 +61,8 @@ class FriendCardExchangeConsentTest { ), ) } + + 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 index 5aea63f9b..1955dc91e 100644 --- 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 @@ -89,6 +89,33 @@ class FriendCardIssuerTest { assertTrue(saved.permissions.canJoinAutomatically) } + @Test + fun `approved exchange promotes the accepter pending 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.receiveRequest(card, "Robin", NOW) + val receiver = FriendCardReceiver(store) + + val result = receiver.confirmPending(peerId) + + assertIs< + Either.Right< + com.minekube.connect.share.friend.SavedFriend, + > + >(result) + assertEquals(peerId, store.all().single().peerId) + assertTrue(store.pendingRequests().isEmpty()) + } + @Test fun `card issuer resolves the persisted endpoint asynchronously`() = runBlocking { 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 index 80f572438..1345927e1 100644 --- 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 @@ -8,14 +8,14 @@ import com.minekube.connect.share.fabric.FabricGuestDirectNode import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.GuestJoinTarget import com.minekube.connect.share.fabric.RemoteFriendPresence +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendStore 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 com.minekube.connect.share.friend.FriendStore -import com.minekube.connect.share.friend.FriendPermissions import java.nio.file.Path import java.security.KeyPairGenerator import java.security.Signature @@ -24,8 +24,8 @@ import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue 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 @@ -35,25 +35,48 @@ class FriendsViewModelTest { lateinit var tempDir: Path @Test - fun `accepting one link exposes a safe saved friend summary`() { + fun `receiving one link exposes only a pending request`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - assertTrue(viewModel.accept(signedLink(), "Robin", NOW)) + assertTrue(viewModel.receiveRequest(signedLink(), "Robin", NOW)) - val friend = viewModel.state.value.friends.single() - assertEquals(PEER_ID, friend.peerId) - assertEquals("Robin", friend.displayName) - assertTrue(friend.connectAvailable) - assertTrue(friend.permissions.notifyWhenOnline) + val request = viewModel.state.value.pendingRequests.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 `pending request never exposes presence as a friend`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.receiveRequest(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.pendingRequests.single().peerId, + ) + } + @Test fun `invalid friend link stays on the add flow with a useful message`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - val accepted = viewModel.accept( + val accepted = viewModel.receiveRequest( "minekube://share/not-a-valid-link", "Robin", NOW, @@ -66,8 +89,9 @@ class FriendsViewModelTest { @Test fun `saved friend can be renamed configured and removed`() { - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(signedLink(), "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.rename(PEER_ID, "Robin from Discord") viewModel.updatePermissions( @@ -95,8 +119,9 @@ class FriendsViewModelTest { fun `matching discovery marks a saved friend world ready to join`() { val link = signedLink() val invitation = ShareInviteCodec.decode(link, NOW).getOrNull()!! - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(link, "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(link, "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.updatePresence( listOf( @@ -121,8 +146,9 @@ class FriendsViewModelTest { @Test fun `Connect presence marks a saved friend online across networks`() { - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(signedLink(), "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.updateRemotePresence( mapOf( @@ -160,8 +186,9 @@ class FriendsViewModelTest { link, ), ) - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(link, "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(link, "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.updatePresence( listOf( DiscoveredLanShare( @@ -184,6 +211,39 @@ class FriendsViewModelTest { browser.close() } + @Test + fun `accepting a pending 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.receiveRequest(link, "Robin", NOW) + + val result = viewModel.joinPending( + peerId = PEER_ID, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + assertTrue(viewModel.state.value.friends.isEmpty()) + browser.close() + } + private fun signedLink(): String { val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() val payload = ShareInvitePayload( From a0fe2636b5222d43fc46287cb46a1bfac5f57186 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 09:28:52 +0200 Subject: [PATCH 034/188] fix(share): send friend requests remotely --- .../connect/share/friend/FriendStore.kt | 25 ++++++--- .../connect/share/friend/FriendStoreTest.kt | 54 ++++++++++++++----- .../fabric/v1_21_11/FriendCardNetworking.kt | 2 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 46 +++++++++------- .../assets/connect-share/lang/de_de.json | 29 +++++----- .../assets/connect-share/lang/en_us.json | 33 ++++++------ .../v1_21_11/Fabric12111ArtifactTest.kt | 28 ++++++---- .../fabric/v26_2/FriendCardNetworking.kt | 2 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 46 +++++++++------- .../assets/connect-share/lang/de_de.json | 29 +++++----- .../assets/connect-share/lang/en_us.json | 33 ++++++------ .../fabric/v26_2/Fabric262ArtifactTest.kt | 28 ++++++---- .../connect/share/fabric/FriendCardIssuer.kt | 4 +- .../share/fabric/ui/FriendsViewModel.kt | 28 +++++----- .../share/fabric/FriendCardIssuerTest.kt | 8 +-- .../share/fabric/ui/FriendsViewModelTest.kt | 25 +++++---- 16 files changed, 245 insertions(+), 175 deletions(-) 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 index ea886d027..6981ad12a 100644 --- 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 @@ -33,7 +33,7 @@ data class FriendPermissions( ) enum class FriendRelationshipStatus { - PENDING_INCOMING, + PENDING_OUTGOING, CONFIRMED, } @@ -90,10 +90,10 @@ class FriendStore( } @Synchronized - fun pendingRequests(): List = + fun outgoingRequests(): List = read().filter { it.relationshipStatus == - FriendRelationshipStatus.PENDING_INCOMING + FriendRelationshipStatus.PENDING_OUTGOING } @Synchronized @@ -110,7 +110,7 @@ class FriendStore( ) @Synchronized - fun receiveRequest( + fun sendRequest( invitationUri: String, displayName: String, now: Instant = Instant.now(), @@ -119,12 +119,12 @@ class FriendStore( invitationUri = invitationUri, displayName = displayName, relationshipStatus = - FriendRelationshipStatus.PENDING_INCOMING, + FriendRelationshipStatus.PENDING_OUTGOING, now = now, ) @Synchronized - fun confirmPending( + fun confirmOutgoing( peerId: String, ): Either = update(peerId) { friend -> friend.copy( @@ -294,7 +294,7 @@ class FriendStore( ) val relationshipStatus = json .optionalString("relationshipStatus") - ?.let(FriendRelationshipStatus::valueOf) + ?.let(::parseRelationshipStatus) ?: legacyRelationshipStatus( minecraftUuid = minecraftUuid, permissions = parsedPermissions, @@ -433,7 +433,16 @@ class FriendStore( ) { FriendRelationshipStatus.CONFIRMED } else { - FriendRelationshipStatus.PENDING_INCOMING + 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 = 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 index d5dc67bf5..47fa5da03 100644 --- 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 @@ -24,31 +24,31 @@ class FriendStoreTest { lateinit var tempDir: Path @Test - fun `receiving a signed link stores only a pending request`() { + fun `sending a signed link stores only an outgoing request`() { val store = FriendStore(tempDir) val request = assertIs>( - store.receiveRequest(signedLink(), "Robin", NOW), + store.sendRequest(signedLink(), "Robin", NOW), ).value assertEquals( - FriendRelationshipStatus.PENDING_INCOMING, + FriendRelationshipStatus.PENDING_OUTGOING, request.relationshipStatus, ) assertTrue(store.all().isEmpty()) assertEquals( listOf(request), - FriendStore(tempDir).pendingRequests(), + FriendStore(tempDir).outgoingRequests(), ) } @Test - fun `confirming a pending request promotes it across restarts`() { + fun `confirming an outgoing request promotes it across restarts`() { val store = FriendStore(tempDir) - store.receiveRequest(signedLink(), "Robin", NOW) + store.sendRequest(signedLink(), "Robin", NOW) val confirmed = assertIs>( - store.confirmPending(PEER_ID), + store.confirmOutgoing(PEER_ID), ).value assertEquals( @@ -59,16 +59,16 @@ class FriendStoreTest { listOf(confirmed), FriendStore(tempDir).all(), ) - assertTrue(FriendStore(tempDir).pendingRequests().isEmpty()) + assertTrue(FriendStore(tempDir).outgoingRequests().isEmpty()) } @Test - fun `receiving the same link never demotes a confirmed friend`() { + fun `sending the same link never demotes a confirmed friend`() { val store = FriendStore(tempDir) store.accept(signedLink(), "Robin", NOW) val received = assertIs>( - store.receiveRequest(signedLink(), "Robin", NOW), + store.sendRequest(signedLink(), "Robin", NOW), ).value assertEquals( @@ -76,11 +76,11 @@ class FriendStoreTest { received.relationshipStatus, ) assertEquals(PEER_ID, store.all().single().peerId) - assertTrue(store.pendingRequests().isEmpty()) + assertTrue(store.outgoingRequests().isEmpty()) } @Test - fun `legacy unverified relationships migrate to pending`() { + fun `legacy unverified relationships migrate to outgoing`() { val store = FriendStore(tempDir) store.accept(signedLink(), "Robin", NOW) stripRelationshipStatus() @@ -88,7 +88,22 @@ class FriendStoreTest { val migrated = FriendStore(tempDir) assertTrue(migrated.all().isEmpty()) - assertEquals(PEER_ID, migrated.pendingRequests().single().peerId) + 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 @@ -104,7 +119,7 @@ class FriendStoreTest { val migrated = FriendStore(tempDir) assertEquals(PEER_ID, migrated.all().single().peerId) - assertTrue(migrated.pendingRequests().isEmpty()) + assertTrue(migrated.outgoingRequests().isEmpty()) } @Test @@ -246,6 +261,17 @@ class FriendStoreTest { 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 = 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 index ad8d412c3..ddd793d95 100644 --- 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 @@ -84,7 +84,7 @@ object FriendCardNetworking { FriendCardPayload(invitation), ) scope.launch(Dispatchers.IO) { - receiver.confirmPending(exchange.peerId) + receiver.confirmOutgoing(exchange.peerId) } } } 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 index 06cd8441c..03e746041 100644 --- 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 @@ -133,11 +133,11 @@ class ShareJoinScreen( ) val state = friends.state.value - val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - pending.size, + MAX_VISIBLE_RELATIONSHIPS - outgoing.size, ) - if (pending.isEmpty() && saved.isEmpty()) { + if (outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -145,7 +145,7 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - pending.forEachIndexed { index, request -> + outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 addRenderableWidget( StringWidget( @@ -154,7 +154,7 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.pending_request", + "connect_share.friends.outgoing_request", request.displayName, ), font, @@ -163,16 +163,16 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.accept_request", + "connect_share.friends.retry_request", ), ) { - joinPending(request.peerId) + joinOutgoing(request.peerId) }.bounds(width / 2 + 23, y, 62, 20).build(), ) addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.decline_request", + "connect_share.friends.cancel_request", ), ) { friends.remove(request.peerId) @@ -181,7 +181,7 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (pending.size + index) * 26 + val y = 58 + (outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -341,16 +341,17 @@ class ShareJoinScreen( primaryButton = addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.save_request", + "connect_share.friends.send_request", ), ) { - if (friends.receiveRequest(invitationValue, nameValue)) { - invitationValue = "" - nameValue = "" - mode = Mode.FRIENDS + val peerId = friends.sendRequest( + invitationValue, + nameValue, + ) + if (peerId == null) { rebuildWidgets() } else { - rebuildWidgets() + joinOutgoing(peerId) } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) @@ -555,7 +556,7 @@ class ShareJoinScreen( } } - private fun joinPending(peerId: String) { + private fun joinOutgoing(peerId: String) { if (joining) return joining = true joiningPeerId = peerId @@ -563,7 +564,7 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - friends.joinPending( + friends.joinOutgoing( peerId = peerId, browser = browser, authMode = authMode(), @@ -624,12 +625,17 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val pendingRequest = state.pendingRequests.firstOrNull { + val outgoingRequest = state.outgoingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( joiningFriend?.displayName - ?: pendingRequest?.displayName + ?: outgoingRequest?.let { + Component.translatable( + "connect_share.friends.connecting_request", + it.displayName, + ).string + } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -638,7 +644,7 @@ class ShareJoinScreen( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = joiningFriend?.permissions?.canSeeMyWorlds - ?: (pendingRequest != null), + ?: (outgoingRequest != null), ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( 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 index 43091293b..f03f09c8d 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Beitrittsanfragen", + "connect_share.status.requests": "Freundschafts- und Beitrittsanfragen", "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 · %s", - "connect_share.status.allow": "Erlauben", + "connect_share.status.request": "%s möchte sich verbinden · %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 den Beitritt.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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 Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", - "connect_share.friends.pending_request": "Anfrage von %s", - "connect_share.friends.accept_request": "Annehmen", - "connect_share.friends.decline_request": "Ablehnen", + "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.retry_request": "Erneut", + "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", "connect_share.friends.name": "Name des Freundes", - "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", "connect_share.friends.save": "Freund speichern", - "connect_share.friends.save_request": "Anfrage 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", @@ -76,8 +77,8 @@ "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": "Freund möchte beitreten", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.join_request": "Freundschafts- oder Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index aa60a7bf6..04a8a1cb2 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Join requests", + "connect_share.status.requests": "Friend and join requests", "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 · %s", - "connect_share.status.allow": "Allow", - "connect_share.status.deny": "Deny", + "connect_share.status.request": "%s wants to connect · %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 to join.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends or requests yet. Add a friend's link to send a request.", - "connect_share.friends.pending_request": "Request from %s", - "connect_share.friends.accept_request": "Accept", - "connect_share.friends.decline_request": "Decline", + "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", - "connect_share.friends.name": "Friend name", - "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", - "connect_share.friends.save_request": "Save request", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", @@ -76,8 +77,8 @@ "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": "Friend wants to join", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.join_request": "Friend or join request", + "connect_share.notification.join_request_detail": "%s is waiting. 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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index 5e878dc3d..11aef74e7 100644 --- 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 @@ -39,21 +39,28 @@ class Fabric12111ArtifactTest { "\"Copy my friend link\"" in language, ) assertTrue( - "\"connect_share.friends.save_request\": " + - "\"Save request\"" in language, + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, ) assertTrue( - "\"connect_share.friends.pending_request\": " + - "\"Request from %s\"" in language, + "\"connect_share.friends.outgoing_request\": " + + "\"Request to %s\"" in language, ) assertTrue( - "\"connect_share.friends.accept_request\": \"Accept\"" in + "\"connect_share.friends.retry_request\": \"Retry\"" in language, ) assertTrue( - "\"connect_share.friends.decline_request\": \"Decline\"" in + "\"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) } } @@ -73,13 +80,14 @@ class Fabric12111ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) - assertTrue("receiveRequest" in bytecode) - assertTrue("joinPending" in bytecode) + assertTrue("sendRequest" in bytecode) + assertTrue("joinOutgoing" in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) } } @Test - fun `approved card exchange promotes a pending request`() { + fun `approved card exchange promotes an outgoing request`() { JarFile(artifact().toFile()).use { jar -> val bytecode = jar.entries().asSequence() .filter { @@ -94,7 +102,7 @@ class Fabric12111ArtifactTest { } } - assertTrue("confirmPending" in bytecode) + assertTrue("confirmOutgoing" in bytecode) } } 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 index a8702303c..fe7ee0db0 100644 --- 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 @@ -84,7 +84,7 @@ object FriendCardNetworking { FriendCardPayload(invitation), ) scope.launch(Dispatchers.IO) { - receiver.confirmPending(exchange.peerId) + receiver.confirmOutgoing(exchange.peerId) } } } 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 index 8382a9f5c..514835ab1 100644 --- 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 @@ -133,11 +133,11 @@ class ShareJoinScreen( ) val state = friends.state.value - val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - pending.size, + MAX_VISIBLE_RELATIONSHIPS - outgoing.size, ) - if (pending.isEmpty() && saved.isEmpty()) { + if (outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -145,7 +145,7 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - pending.forEachIndexed { index, request -> + outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 addRenderableWidget( StringWidget( @@ -154,7 +154,7 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.pending_request", + "connect_share.friends.outgoing_request", request.displayName, ), font, @@ -163,16 +163,16 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.accept_request", + "connect_share.friends.retry_request", ), ) { - joinPending(request.peerId) + joinOutgoing(request.peerId) }.bounds(width / 2 + 23, y, 62, 20).build(), ) addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.decline_request", + "connect_share.friends.cancel_request", ), ) { friends.remove(request.peerId) @@ -181,7 +181,7 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (pending.size + index) * 26 + val y = 58 + (outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -341,16 +341,17 @@ class ShareJoinScreen( primaryButton = addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.save_request", + "connect_share.friends.send_request", ), ) { - if (friends.receiveRequest(invitationValue, nameValue)) { - invitationValue = "" - nameValue = "" - mode = Mode.FRIENDS + val peerId = friends.sendRequest( + invitationValue, + nameValue, + ) + if (peerId == null) { rebuildWidgets() } else { - rebuildWidgets() + joinOutgoing(peerId) } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) @@ -555,7 +556,7 @@ class ShareJoinScreen( } } - private fun joinPending(peerId: String) { + private fun joinOutgoing(peerId: String) { if (joining) return joining = true joiningPeerId = peerId @@ -563,7 +564,7 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - friends.joinPending( + friends.joinOutgoing( peerId = peerId, browser = browser, authMode = authMode(), @@ -623,12 +624,17 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val pendingRequest = state.pendingRequests.firstOrNull { + val outgoingRequest = state.outgoingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( joiningFriend?.displayName - ?: pendingRequest?.displayName + ?: outgoingRequest?.let { + Component.translatable( + "connect_share.friends.connecting_request", + it.displayName, + ).string + } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -637,7 +643,7 @@ class ShareJoinScreen( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = joiningFriend?.permissions?.canSeeMyWorlds - ?: (pendingRequest != null), + ?: (outgoingRequest != null), ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( 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 index 43091293b..f03f09c8d 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Beitrittsanfragen", + "connect_share.status.requests": "Freundschafts- und Beitrittsanfragen", "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 · %s", - "connect_share.status.allow": "Erlauben", + "connect_share.status.request": "%s möchte sich verbinden · %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 den Beitritt.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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 Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", - "connect_share.friends.pending_request": "Anfrage von %s", - "connect_share.friends.accept_request": "Annehmen", - "connect_share.friends.decline_request": "Ablehnen", + "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.retry_request": "Erneut", + "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", "connect_share.friends.name": "Name des Freundes", - "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", "connect_share.friends.save": "Freund speichern", - "connect_share.friends.save_request": "Anfrage 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", @@ -76,8 +77,8 @@ "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": "Freund möchte beitreten", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.join_request": "Freundschafts- oder Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index aa60a7bf6..04a8a1cb2 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Join requests", + "connect_share.status.requests": "Friend and join requests", "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 · %s", - "connect_share.status.allow": "Allow", - "connect_share.status.deny": "Deny", + "connect_share.status.request": "%s wants to connect · %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 to join.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends or requests yet. Add a friend's link to send a request.", - "connect_share.friends.pending_request": "Request from %s", - "connect_share.friends.accept_request": "Accept", - "connect_share.friends.decline_request": "Decline", + "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", - "connect_share.friends.name": "Friend name", - "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", - "connect_share.friends.save_request": "Save request", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", @@ -76,8 +77,8 @@ "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": "Friend wants to join", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.join_request": "Friend or join request", + "connect_share.notification.join_request_detail": "%s is waiting. 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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index c0ff26576..11d7337a4 100644 --- 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 @@ -39,21 +39,28 @@ class Fabric262ArtifactTest { "\"Copy my friend link\"" in language, ) assertTrue( - "\"connect_share.friends.save_request\": " + - "\"Save request\"" in language, + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, ) assertTrue( - "\"connect_share.friends.pending_request\": " + - "\"Request from %s\"" in language, + "\"connect_share.friends.outgoing_request\": " + + "\"Request to %s\"" in language, ) assertTrue( - "\"connect_share.friends.accept_request\": \"Accept\"" in + "\"connect_share.friends.retry_request\": \"Retry\"" in language, ) assertTrue( - "\"connect_share.friends.decline_request\": \"Decline\"" in + "\"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) } } @@ -75,13 +82,14 @@ class Fabric262ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) - assertTrue("receiveRequest" in bytecode) - assertTrue("joinPending" in bytecode) + assertTrue("sendRequest" in bytecode) + assertTrue("joinOutgoing" in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) } } @Test - fun `approved card exchange promotes a pending request`() { + fun `approved card exchange promotes an outgoing request`() { JarFile(artifact().toFile()).use { jar -> val bytecode = jar.entries().asSequence() .filter { @@ -96,7 +104,7 @@ class Fabric262ArtifactTest { } } - assertTrue("confirmPending" in bytecode) + assertTrue("confirmOutgoing" in bytecode) } } 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 index 661872f3c..1316d647e 100644 --- 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 @@ -19,10 +19,10 @@ data object FriendCardIssueFailure class FriendCardReceiver( private val store: FriendStore, ) { - fun confirmPending( + fun confirmOutgoing( peerId: String, ): Either = - store.confirmPending(peerId) + store.confirmOutgoing(peerId) fun receive( invitation: String, 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 index 943bdf180..e6440d8e9 100644 --- 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 @@ -27,14 +27,14 @@ data class FriendSummary( val worldName: String? = null, ) -data class PendingFriendSummary( +data class OutgoingFriendRequestSummary( val peerId: String, val displayName: String, ) data class FriendsUiState( val friends: List = emptyList(), - val pendingRequests: List = emptyList(), + val outgoingRequests: List = emptyList(), val safeMessage: String? = null, ) @@ -47,19 +47,19 @@ class FriendsViewModel( val state: StateFlow = mutableState.asStateFlow() - fun receiveRequest( + fun sendRequest( invitationUri: String, displayName: String, now: Instant = Instant.now(), - ): Boolean = - store.receiveRequest(invitationUri, displayName, now).fold( + ): String? = + store.sendRequest(invitationUri, displayName, now).fold( ifLeft = { failure -> update { copy(safeMessage = failure.safeMessage) } - false + null }, - ifRight = { + ifRight = { request -> refresh() - true + request.peerId }, ) @@ -124,12 +124,12 @@ class FriendsViewModel( return browser.join(friend, authMode) } - suspend fun joinPending( + suspend fun joinOutgoing( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, ): Either { - val request = pendingRequest(peerId) + val request = outgoingRequest(peerId) ?: return GuestJoinFailure.NoRoute.left() return browser.join(request, authMode) } @@ -139,9 +139,9 @@ class FriendsViewModel( store.all().firstOrNull { it.peerId == peerId } }.getOrNull() - internal fun pendingRequest(peerId: String): SavedFriend? = + internal fun outgoingRequest(peerId: String): SavedFriend? = runCatching { - store.pendingRequests().firstOrNull { + store.outgoingRequests().firstOrNull { it.peerId == peerId } }.getOrNull() @@ -165,8 +165,8 @@ class FriendsViewModel( private fun currentState(): FriendsUiState = FriendsUiState( friends = store.all().map { it.summary() }, - pendingRequests = store.pendingRequests().map { - PendingFriendSummary( + outgoingRequests = store.outgoingRequests().map { + OutgoingFriendRequestSummary( peerId = it.peerId, displayName = it.displayName, ) 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 index 1955dc91e..a7e777ee3 100644 --- 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 @@ -90,7 +90,7 @@ class FriendCardIssuerTest { } @Test - fun `approved exchange promotes the accepter pending request`() = + fun `approved exchange promotes the sender outgoing request`() = runBlocking { val issuer = FriendCardIssuer( dataDirectory = tempDir.resolve("sender"), @@ -102,10 +102,10 @@ class FriendCardIssuerTest { .payload .peerId val store = FriendStore(tempDir.resolve("accepter")) - store.receiveRequest(card, "Robin", NOW) + store.sendRequest(card, "Robin", NOW) val receiver = FriendCardReceiver(store) - val result = receiver.confirmPending(peerId) + val result = receiver.confirmOutgoing(peerId) assertIs< Either.Right< @@ -113,7 +113,7 @@ class FriendCardIssuerTest { > >(result) assertEquals(peerId, store.all().single().peerId) - assertTrue(store.pendingRequests().isEmpty()) + assertTrue(store.outgoingRequests().isEmpty()) } @Test 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 index 1345927e1..fb2306fc2 100644 --- 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 @@ -35,12 +35,15 @@ class FriendsViewModelTest { lateinit var tempDir: Path @Test - fun `receiving one link exposes only a pending request`() { + fun `sending one link exposes only an outgoing request`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - assertTrue(viewModel.receiveRequest(signedLink(), "Robin", NOW)) + assertEquals( + PEER_ID, + viewModel.sendRequest(signedLink(), "Robin", NOW), + ) - val request = viewModel.state.value.pendingRequests.single() + val request = viewModel.state.value.outgoingRequests.single() assertEquals(PEER_ID, request.peerId) assertEquals("Robin", request.displayName) assertTrue(viewModel.state.value.friends.isEmpty()) @@ -49,9 +52,9 @@ class FriendsViewModelTest { } @Test - fun `pending request never exposes presence as a friend`() { + fun `outgoing request never exposes presence as a friend`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.receiveRequest(signedLink(), "Robin", NOW) + viewModel.sendRequest(signedLink(), "Robin", NOW) viewModel.updateRemotePresence( mapOf( @@ -68,7 +71,7 @@ class FriendsViewModelTest { assertTrue(viewModel.state.value.friends.isEmpty()) assertEquals( PEER_ID, - viewModel.state.value.pendingRequests.single().peerId, + viewModel.state.value.outgoingRequests.single().peerId, ) } @@ -76,13 +79,13 @@ class FriendsViewModelTest { fun `invalid friend link stays on the add flow with a useful message`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - val accepted = viewModel.receiveRequest( + val accepted = viewModel.sendRequest( "minekube://share/not-a-valid-link", "Robin", NOW, ) - assertFalse(accepted) + assertEquals(null, accepted) assertTrue(viewModel.state.value.friends.isEmpty()) assertTrue(viewModel.state.value.safeMessage?.isNotBlank() == true) } @@ -212,7 +215,7 @@ class FriendsViewModelTest { } @Test - fun `accepting a pending request can join its signed route`() = runTest { + fun `retrying an outgoing request can join its signed route`() = runTest { val link = signedLink() val node = FakeGuestNode() val browser = FabricShareBrowser.testing( @@ -230,9 +233,9 @@ class FriendsViewModelTest { ), ) val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.receiveRequest(link, "Robin", NOW) + viewModel.sendRequest(link, "Robin", NOW) - val result = viewModel.joinPending( + val result = viewModel.joinOutgoing( peerId = PEER_ID, browser = browser, authMode = DirectP2pAuthMode.OFFLINE, From f51c91985616e527df2924b505a191afea91184f Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 10:12:40 +0200 Subject: [PATCH 035/188] fix(share): deliver friend requests without joining --- .../connect/share/CapturedServerTransport.kt | 7 + .../connect/share/MinecraftShareBridge.kt | 1 + .../connect/share/ShareCoordinator.kt | 2 +- .../connect/share/VersionedMinecraftBridge.kt | 5 +- .../share/admission/AdmissionController.kt | 75 +++- .../share/admission/AdmissionIdentity.kt | 6 + .../friend/FriendControlChannelHandler.kt | 187 ++++++++++ .../connect/share/friend/FriendControlWire.kt | 350 ++++++++++++++++++ .../connect/share/friend/FriendStore.kt | 8 +- .../connect/share/ShareCoordinatorTest.kt | 36 +- .../admission/AdmissionControllerTest.kt | 36 ++ .../friend/FriendControlChannelHandlerTest.kt | 160 ++++++++ .../share/friend/FriendControlWireTest.kt | 80 ++++ .../connect/share/friend/FriendStoreTest.kt | 17 + .../v1_21_11/ConnectShare12111Client.kt | 17 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 281 +++++++++++--- .../fabric/v1_21_11/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 23 +- .../fabric/v26_2/ConnectShare262Client.kt | 17 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 281 +++++++++++--- .../share/fabric/v26_2/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 23 +- .../share/fabric/ConnectShareClient.kt | 16 +- .../fabric/FabricSessionAdmissionGate.kt | 12 + .../share/fabric/FabricShareBootstrap.kt | 26 +- .../share/fabric/FriendRequestClient.kt | 232 ++++++++++++ .../share/fabric/FriendRequestServer.kt | 132 +++++++ .../share/fabric/ui/FriendsViewModel.kt | 31 +- .../fabric/FabricSessionAdmissionGateTest.kt | 22 ++ .../share/fabric/FriendRequestClientTest.kt | 162 ++++++++ .../share/fabric/FriendRequestServerTest.kt | 140 +++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 20 +- 36 files changed, 2297 insertions(+), 174 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt 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 index be28ccc49..8188cfbf5 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -5,6 +5,7 @@ import arrow.core.left import arrow.core.right import com.minekube.connect.share.direct.DirectSessionAttributes import com.minekube.connect.share.direct.DirectSessionRegistry +import com.minekube.connect.share.friend.FriendControlChannelRegistry import io.netty.channel.Channel import io.netty.channel.ChannelInitializer import io.netty.channel.EventLoopGroup @@ -36,6 +37,12 @@ object CapturedServerTransport { DirectSessionRegistry.claim(channel.remoteAddress())?.let { channel.attr(DirectSessionAttributes.SESSION).set(it) } + FriendControlChannelRegistry.createHandler()?.let { + channel.pipeline().addLast( + "connect-share-friend-control", + it, + ) + } channel.pipeline().addLast(initializer) } } 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 index 725063aee..91e29ae4f 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt @@ -4,6 +4,7 @@ import java.net.SocketAddress data class LocalShareTarget( val address: SocketAddress, + val directAddress: SocketAddress = address, val close: suspend () -> Unit, ) 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 index 79ce59a12..0e5711200 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -70,7 +70,7 @@ class ShareCoordinator( acquire = { it.start( options = options, - target = target.address, + target = target.directAddress, connectAddress = connect?.publicAddress, ) }, 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 index 39733eaf6..8f0a35bf5 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -47,7 +47,10 @@ open class VersionedMinecraftBridge( val acquired = ActiveTransport(published, local, admission) active = acquired serverSocketAddress = local.address - LocalShareTarget(local.address) { + LocalShareTarget( + address = local.address, + directAddress = published.address, + ) { close(acquired) } } catch (failure: Throwable) { 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 index 42400cfb7..f1df32251 100644 --- 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 @@ -33,19 +33,30 @@ class AdmissionController( require(maxPending > 0) { "Maximum pending admissions must be positive" } } - suspend fun request(identity: AdmissionIdentity): AdmissionAnswer { + suspend fun request( + identity: AdmissionIdentity, + purpose: AdmissionPurpose = AdmissionPurpose.JOIN, + ): AdmissionAnswer { val lookup = synchronized(lock) { - val key = identity.admissionKey() + val key = identity.admissionKey(purpose) requests[key]?.let { + it.waiters++ return@synchronized RequestLookup.Await(it, startTimeout = false) } - if (connectedCount() >= maxGuests()) { + if ( + purpose == AdmissionPurpose.JOIN && + connectedCount() >= maxGuests() + ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } - if (autoApprove(identity)) { + if ( + purpose == AdmissionPurpose.JOIN && + autoApprove(identity) + ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) } if ( + purpose == AdmissionPurpose.JOIN && identity is AdmissionIdentity.Authenticated && identity.uuid in authenticatedApprovals ) { @@ -60,6 +71,7 @@ class AdmissionController( pending = PendingAdmission( requestId = UUID.randomUUID(), identity = identity, + purpose = purpose, ), ) requests[key] = request @@ -73,7 +85,11 @@ class AdmissionController( if (lookup.startTimeout) { startTimeout(lookup.request) } - lookup.request.answer.await() + try { + lookup.request.answer.await() + } finally { + releaseWaiter(lookup.request) + } } } } @@ -85,7 +101,10 @@ class AdmissionController( it.value.pending.requestId == requestId } ?: return requests.remove(entry.key) - if (allow) { + if ( + allow && + entry.value.pending.purpose == AdmissionPurpose.JOIN + ) { val identity = entry.value.pending.identity if (identity is AdmissionIdentity.Authenticated) { authenticatedApprovals += identity.uuid @@ -139,18 +158,51 @@ class AdmissionController( 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(): AdmissionKey = when (this) { - is AdmissionIdentity.Authenticated -> AdmissionKey.Authenticated(uuid) - is AdmissionIdentity.UnverifiedOffline -> AdmissionKey.Unverified(connectionId) + 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) : AdmissionKey - data class Unverified(val connectionId: String) : AdmissionKey + data class Authenticated( + val uuid: UUID, + val purpose: AdmissionPurpose, + ) : AdmissionKey + + data class Unverified( + val connectionId: String, + val purpose: AdmissionPurpose, + ) : AdmissionKey } private class PendingRequest( @@ -158,6 +210,7 @@ class AdmissionController( val pending: PendingAdmission, val answer: CompletableDeferred = CompletableDeferred(), val timeoutJob: AtomicReference = AtomicReference(), + var waiters: Int = 1, ) private sealed interface 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 index 2d270b391..60aadc558 100644 --- 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 @@ -43,7 +43,13 @@ enum class AdmissionAnswer { 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/friend/FriendControlChannelHandler.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt new file mode 100644 index 000000000..9bb1f98e7 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt @@ -0,0 +1,187 @@ +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.ChannelHandler +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 +} + +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.inspectControlHandshake(accumulated) + ) { + FriendControlDecode.Incomplete -> return + FriendControlDecode.Invalid -> { + context.close() + return + } + + is FriendControlDecode.Decoded -> { + if (!inspected.value) { + passThrough(context, accumulated) + return + } + controlHandshake = true + } + } + } + + when (val decoded = FriendControlWire.decodeRequest(accumulated)) { + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + is FriendControlDecode.Decoded -> { + if (decoded.consumedBytes != accumulated.size) { + context.close() + return + } + beginRequest(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) + val pending = server.handle(context.controlContext(), request) + 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 { + val direct = channel() + .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(), + ) + } +} + +object FriendControlChannelRegistry { + private val installed = AtomicReference() + + fun install(server: FriendControlServer): AutoCloseable { + check(installed.compareAndSet(null, server)) { + "A friend control server is already installed" + } + return AutoCloseable { + installed.compareAndSet(server, null) + } + } + + fun createHandler(): ChannelHandler? = + installed.get()?.let(::FriendControlChannelHandler) +} 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..0208caa61 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt @@ -0,0 +1,350 @@ +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 displayName: String, + val invitation: String, +) + +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 +} + +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 + const val CONTROL_HANDSHAKE_PORT = 24_454 + + private const val STATUS_INTENTION = 1 + private const val HANDSHAKE_PACKET_ID = 0 + private const val STATUS_REQUEST_PACKET_ID = 0 + private const val CONTROL_REQUEST_PACKET_ID = 0x43F1 + private const val CONTROL_RESPONSE_PACKET_ID = 0x43F2 + private const val MAX_ADDRESS_BYTES = 255 + private const val MAX_DISPLAY_NAME_BYTES = 256 + private const val MAX_INVITATION_BYTES = 32_768 + + fun encodeRequest( + protocolVersion: Int, + serverAddress: String, + request: FriendControlRequest, + ): ByteArray { + require(protocolVersion >= 0) { + "Minecraft protocol version must not be negative" + } + require(serverAddress.toByteArray(StandardCharsets.UTF_8).size <= MAX_ADDRESS_BYTES) { + "Minecraft server address is too long" + } + 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(HANDSHAKE_PACKET_ID) + writeVarInt(protocolVersion) + writeString(serverAddress) + write((CONTROL_HANDSHAKE_PORT ushr 8) and 0xff) + write(CONTROL_HANDSHAKE_PORT and 0xff) + writeVarInt(STATUS_INTENTION) + } + output.writePacket { + writeVarInt(STATUS_REQUEST_PACKET_ID) + } + output.writePacket { + writeVarInt(CONTROL_REQUEST_PACKET_ID) + writeLong(request.requestId.mostSignificantBits) + writeLong(request.requestId.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 + } + return decode(bytes) { + val handshake = readPacket() + ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) + handshake.readVarInt() + handshake.readString(MAX_ADDRESS_BYTES) + ensure(handshake.readUnsignedShort() == CONTROL_HANDSHAKE_PORT) + ensure(handshake.readVarInt() == STATUS_INTENTION) + handshake.ensureFinished() + + val statusRequest = readPacket() + ensure( + statusRequest.readVarInt() == STATUS_REQUEST_PACKET_ID, + ) + statusRequest.ensureFinished() + + 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, + displayName = displayName, + invitation = invitation, + ) + } + } + + 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 inspectControlHandshake( + bytes: ByteArray, + ): FriendControlDecode = decode(bytes) { + val handshake = readPacket() + ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) + handshake.readVarInt() + handshake.readString(MAX_ADDRESS_BYTES) + val port = handshake.readUnsignedShort() + val intention = handshake.readVarInt() + handshake.ensureFinished() + port == CONTROL_HANDSHAKE_PORT && + intention == STATUS_INTENTION + } + + 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) + } + } + return output.toByteArray() + } + + 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 + 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.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 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 index 6981ad12a..382655770 100644 --- 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 @@ -83,6 +83,8 @@ sealed interface FriendStoreError { class FriendStore( private val directory: Path, ) { + private var cached: List? = null + @Synchronized fun all(): List = read().filter { @@ -230,7 +232,10 @@ class FriendStore( updated } - private fun read(): List { + private fun read(): List = + cached ?: load().also { cached = it } + + private fun load(): List { Files.createDirectories(directory) if (!Files.exists(friendsFile)) { return emptyList() @@ -359,6 +364,7 @@ class FriendStore( add("friends", entries) } writeAtomic(GSON.toJson(root)) + cached = friends.toList() } private fun writeAtomic(content: String) { 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 index a2c8c9a03..90ec30ea3 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -25,6 +25,36 @@ 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() @@ -311,6 +341,9 @@ class ShareCoordinatorTest { 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, @@ -345,7 +378,8 @@ class ShareCoordinatorTest { val bridge = MinecraftShareBridge { events += "bridge-open" LocalShareTarget( - address = InetSocketAddress.createUnresolved("127.0.0.1", 25565), + address = connectTarget, + directAddress = directTarget, close = { events += "bridge-close" }, 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 index 5af01224c..d6613a9d4 100644 --- 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 @@ -7,12 +7,48 @@ 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 `authenticated UUID approval is reused only during current share`() = runTest { val controller = controller() 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..551f0179d --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt @@ -0,0 +1,160 @@ +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 = FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "localhost", + request = REQUEST, + ).copyOf() + val controlHigh = + FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 + val controlLow = + FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff + val portIndex = ordinary.indices.first { + it + 1 < ordinary.size && + ordinary[it].toInt() and 0xff == controlHigh && + ordinary[it + 1].toInt() and 0xff == controlLow + } + ordinary[portIndex] = (25_565 ushr 8).toByte() + ordinary[portIndex + 1] = 25_565.toByte() + 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( + protocolVersion = 1_075, + serverAddress = "connect-share", + 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( + protocolVersion = 1_075, + serverAddress = "purple-del.play.minekube.net", + request = REQUEST, + ), + ), + ) + channel.readOutbound()?.release() + + channel.close() + + assertTrue(response.isCancelled) + 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", + ) + } +} 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..64b7dae81 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt @@ -0,0 +1,80 @@ +package com.minekube.connect.share.friend + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class FriendControlWireTest { + @Test + fun `request uses a status handshake and round trips without a login`() { + val request = FriendControlRequest( + requestId = REQUEST_ID, + displayName = "bob", + invitation = "minekube://share/signed-bob-card", + ) + + val encoded = FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "purple-del.play.minekube.net", + request = request, + ) + val decoded = assertIs>( + FriendControlWire.decodeRequest(encoded), + ) + + assertEquals(request, decoded.value) + assertEquals(encoded.size, decoded.consumedBytes) + assertTrue(FriendControlWire.isStatusHandshake(encoded)) + } + + @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, + ) + + 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 `partial and oversized control frames are never accepted`() { + val encoded = FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "purple-del.play.minekube.net", + 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") + } +} 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 index 47fa5da03..299367132 100644 --- 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 @@ -23,6 +23,23 @@ 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) 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 index e1bc245a9..6237e7c3d 100644 --- 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 @@ -1,6 +1,7 @@ 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.FabricLocalLoginAdmission import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate @@ -47,7 +48,9 @@ class ConnectShare12111Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), + minecraftProtocolVersion = SharedConstants.getProtocolVersion(), worldAvailable = client.hasSingleplayerServer(), + friendStore = friendStore, playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, @@ -97,7 +100,7 @@ class ConnectShare12111Client : ClientModInitializer { FriendCardNetworking.install( scope = scope, issuer = installation.friendCardIssuer, - receiver = FriendCardReceiver(friendStore), + receiver = installation.friendCardReceiver, approvedJoins = installation.approvedJoins, ) ConnectShareClient.install(installation) @@ -121,10 +124,18 @@ class ConnectShare12111Client : ClientModInitializer { minecraft.toastManager, admissionToastId, Component.translatable( - "connect_share.notification.join_request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, ), Component.translatable( - "connect_share.notification.join_request_detail", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, request.identity.name, ), ) 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 index 03e746041..879901f31 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.friend.FriendControlRequest import com.minekube.connect.share.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -14,6 +15,7 @@ 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 @@ -27,6 +29,7 @@ import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component +import java.util.UUID class ShareJoinScreen( private val parent: Screen, @@ -55,6 +58,10 @@ class ShareJoinScreen( private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() override fun init() { if (scope == null) { @@ -147,15 +154,16 @@ class ShareJoinScreen( } outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 + val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( width / 2 - 155, y, 174, 20, - Component.translatable( - "connect_share.friends.outgoing_request", + outgoingRequestLabel( request.displayName, + deliveryState, ), font, ), @@ -163,11 +171,15 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.retry_request", + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", ), ) { - joinOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, ) addRenderableWidget( Button.builder( @@ -175,8 +187,7 @@ class ShareJoinScreen( "connect_share.friends.cancel_request", ), ) { - friends.remove(request.peerId) - rebuildWidgets() + cancelOutgoing(request.peerId) }.bounds(width / 2 + 89, y, 66, 20).build(), ) } @@ -344,15 +355,7 @@ class ShareJoinScreen( "connect_share.friends.send_request", ), ) { - val peerId = friends.sendRequest( - invitationValue, - nameValue, - ) - if (peerId == null) { - rebuildWidgets() - } else { - joinOutgoing(peerId) - } + createFriendRequest() }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) secondaryButton = addRenderableWidget( @@ -442,19 +445,28 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.save_changes"), ) { - friends.rename(friend.peerId, nameValue) - friends.updatePermissions( - friend.peerId, - FriendPermissions( - notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, - canJoinAutomatically = autoJoin.selected(), - ), - ) - mode = Mode.FRIENDS - selectedPeerId = null - rebuildWidgets() + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = + autoJoin.selected(), + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -497,12 +509,20 @@ class ShareJoinScreen( "connect_share.friends.remove_confirm.confirm", ), ) { - friends.remove(friend.peerId) - removeConfirmation = false - mode = Mode.FRIENDS - selectedPeerId = null - nameValue = "" - rebuildWidgets() + 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 + nameValue = "" + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -556,25 +576,160 @@ class ShareJoinScreen( } } - private fun joinOutgoing(peerId: String) { - if (joining) return - joining = true - joiningPeerId = peerId - reciprocalPairing = true + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true safeMessage = null refresh() - scope?.launch { - friends.joinOutgoing( + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, authMode = authMode(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -625,17 +780,8 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val outgoingRequest = state.outgoingRequests.firstOrNull { - it.peerId == joiningPeerId - } val data = ServerData( joiningFriend?.displayName - ?: outgoingRequest?.let { - Component.translatable( - "connect_share.friends.connecting_request", - it.displayName, - ).string - } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -643,8 +789,7 @@ class ShareJoinScreen( val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds - ?: (outgoingRequest != null), + joiningFriend?.permissions?.canSeeMyWorlds == true, ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( @@ -664,7 +809,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() primaryButton?.active = - !joining && friendLinkState != FriendLinkState.COPYING && + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -703,6 +849,18 @@ class ShareJoinScreen( ) } + 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 @@ -750,6 +908,15 @@ class ShareJoinScreen( 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 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 index d820d99de..387f40dce 100644 --- 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 @@ -3,6 +3,7 @@ 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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -106,7 +107,11 @@ class ShareStatusScreen( "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( - "connect_share.status.request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, identity.name, badge, ) 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 index f03f09c8d..c9feb35ec 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -77,8 +84,10 @@ "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": "Freundschafts- oder Beitrittsanfrage", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "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 ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index 04a8a1cb2..1f1649285 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -77,8 +84,10 @@ "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": "Friend or join request", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to accept or decline.", + "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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index 11aef74e7..589eca7b3 100644 --- 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 @@ -67,21 +67,26 @@ class Fabric12111ArtifactTest { @Test fun `friend removal confirmation stays inside the friends screen`() { JarFile(artifact().toFile()).use { jar -> - val screen = jar.getJarEntry( - "com/minekube/connect/share/fabric/v1_21_11/" + - "ShareJoinScreen.class", - ) - assertNotNull(screen) - val bytecode = jar.getInputStream(screen).use { - it.readBytes().toString(Charsets.ISO_8859_1) - } + 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("joinOutgoing" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("joinOutgoing" !in bytecode) assertFalse("connect_share.friends.accept_request" in bytecode) } } 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 index 34910bf48..2b43f0276 100644 --- 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 @@ -1,6 +1,7 @@ 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.FabricLocalLoginAdmission import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate @@ -48,7 +49,9 @@ class ConnectShare262Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), + minecraftProtocolVersion = SharedConstants.getProtocolVersion(), worldAvailable = client.hasSingleplayerServer(), + friendStore = friendStore, playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, @@ -98,7 +101,7 @@ class ConnectShare262Client : ClientModInitializer { FriendCardNetworking.install( scope = scope, issuer = installation.friendCardIssuer, - receiver = FriendCardReceiver(friendStore), + receiver = installation.friendCardReceiver, approvedJoins = installation.approvedJoins, ) ConnectShareClient.install(installation) @@ -122,10 +125,18 @@ class ConnectShare262Client : ClientModInitializer { minecraft.gui.toastManager(), admissionToastId, Component.translatable( - "connect_share.notification.join_request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, ), Component.translatable( - "connect_share.notification.join_request_detail", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, request.identity.name, ), ) 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 index 514835ab1..d109173a3 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.friend.FriendControlRequest import com.minekube.connect.share.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -14,6 +15,7 @@ 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 @@ -27,6 +29,7 @@ import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component +import java.util.UUID class ShareJoinScreen( private val parent: Screen, @@ -55,6 +58,10 @@ class ShareJoinScreen( private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() override fun init() { if (scope == null) { @@ -147,15 +154,16 @@ class ShareJoinScreen( } outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 + val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( width / 2 - 155, y, 174, 20, - Component.translatable( - "connect_share.friends.outgoing_request", + outgoingRequestLabel( request.displayName, + deliveryState, ), font, ), @@ -163,11 +171,15 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.retry_request", + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", ), ) { - joinOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, ) addRenderableWidget( Button.builder( @@ -175,8 +187,7 @@ class ShareJoinScreen( "connect_share.friends.cancel_request", ), ) { - friends.remove(request.peerId) - rebuildWidgets() + cancelOutgoing(request.peerId) }.bounds(width / 2 + 89, y, 66, 20).build(), ) } @@ -344,15 +355,7 @@ class ShareJoinScreen( "connect_share.friends.send_request", ), ) { - val peerId = friends.sendRequest( - invitationValue, - nameValue, - ) - if (peerId == null) { - rebuildWidgets() - } else { - joinOutgoing(peerId) - } + createFriendRequest() }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) secondaryButton = addRenderableWidget( @@ -442,19 +445,28 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.save_changes"), ) { - friends.rename(friend.peerId, nameValue) - friends.updatePermissions( - friend.peerId, - FriendPermissions( - notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, - canJoinAutomatically = autoJoin.selected(), - ), - ) - mode = Mode.FRIENDS - selectedPeerId = null - rebuildWidgets() + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = + autoJoin.selected(), + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -497,12 +509,20 @@ class ShareJoinScreen( "connect_share.friends.remove_confirm.confirm", ), ) { - friends.remove(friend.peerId) - removeConfirmation = false - mode = Mode.FRIENDS - selectedPeerId = null - nameValue = "" - rebuildWidgets() + 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 + nameValue = "" + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -556,25 +576,160 @@ class ShareJoinScreen( } } - private fun joinOutgoing(peerId: String) { - if (joining) return - joining = true - joiningPeerId = peerId - reciprocalPairing = true + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true safeMessage = null refresh() - scope?.launch { - friends.joinOutgoing( + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, authMode = authMode(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -624,17 +779,8 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val outgoingRequest = state.outgoingRequests.firstOrNull { - it.peerId == joiningPeerId - } val data = ServerData( joiningFriend?.displayName - ?: outgoingRequest?.let { - Component.translatable( - "connect_share.friends.connecting_request", - it.displayName, - ).string - } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -642,8 +788,7 @@ class ShareJoinScreen( val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds - ?: (outgoingRequest != null), + joiningFriend?.permissions?.canSeeMyWorlds == true, ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( @@ -663,7 +808,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() primaryButton?.active = - !joining && friendLinkState != FriendLinkState.COPYING && + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -702,6 +848,18 @@ class ShareJoinScreen( ) } + 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 @@ -749,6 +907,15 @@ class ShareJoinScreen( 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 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 index c3b3ace0d..7de617042 100644 --- 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 @@ -3,6 +3,7 @@ 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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -106,7 +107,11 @@ class ShareStatusScreen( "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( - "connect_share.status.request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, identity.name, badge, ) 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 index f03f09c8d..c9feb35ec 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -77,8 +84,10 @@ "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": "Freundschafts- oder Beitrittsanfrage", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "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 ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index 04a8a1cb2..1f1649285 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -77,8 +84,10 @@ "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": "Friend or join request", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to accept or decline.", + "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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index 11d7337a4..819fb224b 100644 --- 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 @@ -67,14 +67,18 @@ class Fabric262ArtifactTest { @Test fun `friend removal confirmation stays inside the friends screen`() { JarFile(artifact().toFile()).use { jar -> - val screen = jar.getJarEntry( - "com/minekube/connect/share/fabric/v26_2/" + - "ShareJoinScreen.class", - ) - assertNotNull(screen) - val bytecode = jar.getInputStream(screen).use { - it.readBytes().toString(Charsets.ISO_8859_1) - } + 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, @@ -83,7 +87,8 @@ class Fabric262ArtifactTest { "connect_share.friends.remove_confirm.confirm" in bytecode, ) assertTrue("sendRequest" in bytecode) - assertTrue("joinOutgoing" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("joinOutgoing" !in bytecode) assertFalse("connect_share.friends.accept_request" in bytecode) } } 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 index b85069c23..a9f13c2e9 100644 --- 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 @@ -15,7 +15,10 @@ data class ConnectShareInstallation( val viewModel: ShareViewModel, val runtime: ConnectShareRuntime, val friendCardIssuer: FriendCardIssuer, + val friendCardReceiver: FriendCardReceiver, + val friendRequestClient: FriendRequestClient, val approvedJoins: ApprovedJoinTracker, + val friendControlLease: AutoCloseable, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -76,6 +79,14 @@ object ConnectShareClient { fun friendCardIssuer(): FriendCardIssuer = checkNotNull(installation).friendCardIssuer + @JvmStatic + fun friendCardReceiver(): FriendCardReceiver = + checkNotNull(installation).friendCardReceiver + + @JvmStatic + fun friendRequestClient(): FriendRequestClient = + checkNotNull(installation).friendRequestClient + @JvmStatic fun armFriendCardExchange(peerId: String) { friendCardConsent.arm(peerId) @@ -97,7 +108,10 @@ object ConnectShareClient { fun shutdown() { friendCardConsent.cancel() guestLease.close() - installation?.runtime?.shutdown() + installation?.let { installed -> + installed.friendControlLease.close() + installed.runtime.shutdown() + } } private fun isShareActive(): Boolean = when ( 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 index 9c9bdcfbf..7637b0cb5 100644 --- 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 @@ -34,6 +34,11 @@ class FabricSessionAdmissionGate( override fun request( proposal: SessionProposal, ): CompletionStage { + if (proposal.isStatusProbe()) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.allow(), + ) + } if (proposal.session.auth.passthrough) { return CompletableFuture.completedFuture( SessionAdmissionDecision.deferToLocalLogin(), @@ -85,6 +90,13 @@ class FabricSessionAdmissionGate( 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 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 index 89fe2f6fd..a95e58d88 100644 --- 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 @@ -9,6 +9,7 @@ import com.minekube.connect.share.admission.AdmissionIdentity import com.minekube.connect.share.fabric.ui.ShareViewModel import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendControlChannelRegistry import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -28,7 +29,9 @@ object FabricShareBootstrap { scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, + minecraftProtocolVersion: Int, worldAvailable: Boolean, + friendStore: FriendStore, playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, bridgeFactory: @@ -44,7 +47,6 @@ object FabricShareBootstrap { httpClient: OkHttpClient = OkHttpClient(), ): ConnectShareInstallation { val viewModelReference = AtomicReference() - val friendStore = FriendStore(dataDirectory) val approvedJoins = ApprovedJoinTracker() val admission = AdmissionController( scope = scope, @@ -146,13 +148,29 @@ object FabricShareBootstrap { resumeShare = viewModel::resumeIfEnabled, worldAvailabilityChanged = viewModel::setWorldAvailable, ) + val friendCardIssuer = FriendCardIssuer(dataDirectory) { + "${identityStore.currentOrCreate().endpoint}.play.minekube.net" + } + val friendCardReceiver = FriendCardReceiver(friendStore) + val friendRequestServer = FriendRequestServer( + scope = scope, + admission = admission, + issuer = friendCardIssuer, + receiver = friendCardReceiver, + friendStore = friendStore, + ) + val friendControlLease = + FriendControlChannelRegistry.install(friendRequestServer) return ConnectShareInstallation( viewModel = viewModel, runtime = runtime, - friendCardIssuer = FriendCardIssuer(dataDirectory) { - "${identityStore.currentOrCreate().endpoint}.play.minekube.net" - }, + friendCardIssuer = friendCardIssuer, + friendCardReceiver = friendCardReceiver, + friendRequestClient = FriendRequestClient( + minecraftProtocolVersion, + ), approvedJoins = approvedJoins, + friendControlLease = friendControlLease, screens = screens, guestScreens = guestScreens, ) 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..d0135036d --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt @@ -0,0 +1,232 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +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 java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.InetSocketAddress +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" + } +} + +class FriendRequestClient( + private val protocolVersion: Int, + 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, + request: FriendControlRequest, + onReceived: () -> Unit, + ): Either = withContext(ioDispatcher) { + target.use { + val route = target.routeTarget() + val socket = Socket() + val cancellation = coroutineContext[Job] + ?.invokeOnCompletion { socket.close() } + try { + socket.connect( + route.socketAddress, + connectTimeout.toMillis().toInt(), + ) + socket.soTimeout = READ_POLL_MILLIS + socket.getOutputStream().apply { + write( + FriendControlWire.encodeRequest( + protocolVersion = protocolVersion, + serverAddress = route.handshakeAddress, + 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() + } + } + outcome + } 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 fun GuestJoinTarget.routeTarget(): RouteTarget = when (this) { + is GuestJoinTarget.Connect -> { + val parsed = parseAddress(publicAddress) + RouteTarget( + socketAddress = parsed, + handshakeAddress = parsed.hostString, + ) + } + + is GuestJoinTarget.Direct -> RouteTarget( + socketAddress = localAddress, + handshakeAddress = "connect-share", + ) + } + + private fun parseAddress(value: String): InetSocketAddress { + val trimmed = value.trim() + if (trimmed.startsWith("[")) { + val closing = trimmed.indexOf(']') + require(closing > 1) { "Friend address is invalid" } + val host = trimmed.substring(1, closing) + val port = trimmed.substring(closing + 1) + .removePrefix(":") + .takeIf(String::isNotEmpty) + ?.toInt() + ?: DEFAULT_MINECRAFT_PORT + return InetSocketAddress(host, port) + } + val colon = trimmed.lastIndexOf(':') + val hasSingleColon = + colon > 0 && trimmed.indexOf(':') == colon + val host = if (hasSingleColon) { + trimmed.substring(0, colon) + } else { + trimmed + } + val port = if (hasSingleColon) { + trimmed.substring(colon + 1).toInt() + } else { + DEFAULT_MINECRAFT_PORT + } + return InetSocketAddress(host, port) + } + + private data class RouteTarget( + val socketAddress: InetSocketAddress, + val handshakeAddress: String, + ) + + private companion object { + const val DEFAULT_MINECRAFT_PORT = 25_565 + 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..65a0b2d32 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt @@ -0,0 +1,132 @@ +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.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.FriendControlServer +import com.minekube.connect.share.friend.FriendStore +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 now: () -> Instant = Instant::now, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : FriendControlServer { + 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 + } + + private suspend fun process( + context: FriendControlContext, + request: FriendControlRequest, + ): FriendControlResponse { + val instant = now() + val invitation = ShareInviteCodec.decode( + request.invitation, + instant, + ).getOrNull() ?: return FriendControlResponse.Invalid + val senderPeerId = invitation.payload.peerId + if ( + context.directPeerId != null && + context.directPeerId != senderPeerId + ) { + return FriendControlResponse.Invalid + } + val senderKey = Base64.getEncoder() + .encodeToString(invitation.publicKey) + val existing = friendStore.all().firstOrNull { + it.peerId == senderPeerId + } + if (existing != null) { + if (existing.publicKeyBase64 != senderKey) { + return FriendControlResponse.Invalid + } + 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, + now = instant, + ) + if (received.isLeft()) { + FriendControlResponse.Invalid + } else { + 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 CompletableFuture.cancelJobWhenCancelled( + job: Job, + ) { + whenComplete { _, _ -> + if (isCancelled) { + job.cancel() + } + } + } +} 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 index e6440d8e9..3f1d9281c 100644 --- 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 @@ -103,15 +103,21 @@ class FriendsViewModel( ) fun updatePresence(discovered: List) { + if (this.discovered == discovered) { + return + } this.discovered = discovered - refresh() + refresh(preserveSafeMessage = true) } fun updateRemotePresence( presence: Map, ) { + if (remotePresence == presence) { + return + } remotePresence = presence - refresh() + refresh(preserveSafeMessage = true) } suspend fun join( @@ -124,7 +130,7 @@ class FriendsViewModel( return browser.join(friend, authMode) } - suspend fun joinOutgoing( + suspend fun routeOutgoing( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, @@ -134,6 +140,10 @@ class FriendsViewModel( return browser.join(request, authMode) } + fun reload() { + refresh() + } + internal fun savedFriend(peerId: String): SavedFriend? = runCatching { store.all().firstOrNull { it.peerId == peerId } @@ -146,9 +156,20 @@ class FriendsViewModel( } }.getOrNull() - private fun refresh() { + private fun refresh( + preserveSafeMessage: Boolean = false, + ) { mutableState.value = try { - currentState() + currentState().let { next -> + if (preserveSafeMessage) { + next.copy( + safeMessage = + mutableState.value.safeMessage, + ) + } else { + next + } + } } catch (_: Exception) { mutableState.value.copy( safeMessage = FRIENDS_LOAD_FAILURE, 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 index 7324fef45..a86286a05 100644 --- 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 @@ -23,6 +23,28 @@ import minekube.connect.v1alpha1.WatchServiceOuterClass.Session @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class FabricSessionAdmissionGateTest { + @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() 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..8002beda7 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt @@ -0,0 +1,162 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +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 java.io.ByteArrayOutputStream +import java.net.InetAddress +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 `Connect 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( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + ) + + val result = client.exchange( + target = GuestJoinTarget.Connect( + "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", + ), + 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( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + decisionTimeout = Duration.ofSeconds(30), + ) + val pending = launch { + client.exchange( + target = GuestJoinTarget.Connect( + "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", + ), + request = REQUEST, + onReceived = {}, + ) + } + delay(100) + + pending.cancelAndJoin() + + assertTrue(closed.await(2, TimeUnit.SECONDS)) + remote.join(1_000) + } + + 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 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" + } +} 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..0cf4be038 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt @@ -0,0 +1,140 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionController +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.FriendStore +import java.nio.file.Path +import java.time.Instant +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.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +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) + assertTrue(hostStore.all().single().permissions.canJoinAutomatically) + } + + @Test + fun `decline 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() + admission.answer( + admission.pending.value.single().requestId, + allow = false, + ) + runCurrent() + + assertEquals(FriendControlResponse.Declined, connect.getNow(null)) + assertTrue(hostStore.all().isEmpty()) + } + + 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") + } +} 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 index fb2306fc2..f65b6c38b 100644 --- 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 @@ -90,6 +90,24 @@ class FriendsViewModelTest { assertTrue(viewModel.state.value.safeMessage?.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) @@ -235,7 +253,7 @@ class FriendsViewModelTest { val viewModel = FriendsViewModel(FriendStore(tempDir)) viewModel.sendRequest(link, "Robin", NOW) - val result = viewModel.joinOutgoing( + val result = viewModel.routeOutgoing( peerId = PEER_ID, browser = browser, authMode = DirectP2pAuthMode.OFFLINE, From ddb43e0d71b7565f443e931afb18c7146588162a Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 11:22:53 +0200 Subject: [PATCH 036/188] fix(share): keep friend transports reachable from title --- .../connect/share/CapturedServerTransport.kt | 7 - .../connect/share/ShareConnectionGateway.kt | 184 ++++++++++ .../connect/share/VersionedMinecraftBridge.kt | 108 +++++- .../friend/FriendControlChannelHandler.kt | 17 - .../share/GatewayMinecraftBridgeTest.kt | 182 ++++++++++ .../share/ShareConnectionGatewayTest.kt | 315 ++++++++++++++++++ .../v1_21_11/ConnectShare12111Client.kt | 208 ++++++++---- .../fabric/v1_21_11/Minecraft12111Bridge.kt | 14 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 73 +++- .../assets/connect-share/lang/de_de.json | 5 +- .../assets/connect-share/lang/en_us.json | 5 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 7 + .../fabric/v26_2/ConnectShare262Client.kt | 205 ++++++++---- .../share/fabric/v26_2/Minecraft262Bridge.kt | 14 + .../share/fabric/v26_2/ShareJoinScreen.kt | 73 +++- .../assets/connect-share/lang/de_de.json | 5 +- .../assets/connect-share/lang/en_us.json | 5 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 7 + .../share/fabric/ConnectControlPlane.kt | 56 ++++ .../share/fabric/ConnectShareClient.kt | 41 ++- .../share/fabric/ConnectShareRuntime.kt | 15 +- .../share/fabric/DirectControlPlane.kt | 61 ++++ .../share/fabric/FabricConnectIngress.kt | 6 + .../fabric/FabricSessionAdmissionGate.kt | 7 + .../share/fabric/FabricShareBootstrap.kt | 207 +++++++----- .../share/fabric/FabricShareBrowser.kt | 134 +++++++- .../share/fabric/FriendPairingClient.kt | 88 +++++ .../share/fabric/FriendPresenceMonitor.kt | 58 +++- .../share/fabric/FriendRequestServer.kt | 10 + .../share/fabric/PersistentConnectIngress.kt | 147 ++++++++ .../share/fabric/PersistentDirectIngress.kt | 158 +++++++++ .../share/fabric/ui/FriendsViewModel.kt | 62 +++- .../share/fabric/ConnectControlPlaneTest.kt | 128 +++++++ .../share/fabric/ConnectShareRuntimeTest.kt | 34 +- .../share/fabric/DirectControlPlaneTest.kt | 134 ++++++++ .../fabric/FabricSessionAdmissionGateTest.kt | 35 ++ .../share/fabric/FabricShareBrowserTest.kt | 81 ++++- .../fabric/FriendPairingDirectE2ETest.kt | 253 ++++++++++++++ .../share/fabric/FriendPairingE2ETest.kt | 138 ++++++++ .../share/fabric/FriendPresenceMonitorTest.kt | 107 ++++++ .../fabric/PersistentConnectIngressTest.kt | 130 ++++++++ .../fabric/PersistentDirectIngressTest.kt | 158 +++++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 84 +++++ 43 files changed, 3445 insertions(+), 321 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectControlPlaneTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt 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 index 8188cfbf5..be28ccc49 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -5,7 +5,6 @@ import arrow.core.left import arrow.core.right import com.minekube.connect.share.direct.DirectSessionAttributes import com.minekube.connect.share.direct.DirectSessionRegistry -import com.minekube.connect.share.friend.FriendControlChannelRegistry import io.netty.channel.Channel import io.netty.channel.ChannelInitializer import io.netty.channel.EventLoopGroup @@ -37,12 +36,6 @@ object CapturedServerTransport { DirectSessionRegistry.claim(channel.remoteAddress())?.let { channel.attr(DirectSessionAttributes.SESSION).set(it) } - FriendControlChannelRegistry.createHandler()?.let { - channel.pipeline().addLast( - "connect-share-friend-control", - it, - ) - } channel.pipeline().addLast(initializer) } } 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..07dafaa00 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -0,0 +1,184 @@ +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 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.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +class ShareConnectionGateway private constructor( + private val friendServer: FriendControlServer, +) : CommonPlatformInjector(), AutoCloseable { + private val activeMinecraft = + AtomicReference?>(null) + private val closed = AtomicBoolean() + private val localEventLoop: EventLoopGroup = DefaultEventLoopGroup( + 1, + DefaultThreadFactory("Connect Share gateway local"), + ) + private val directEventLoop: EventLoopGroup = NioEventLoopGroup( + 1, + 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_DISPATCH_HANDLER, + MinecraftDispatchHandler(activeMinecraft), + ) + } + } + + private fun closeAfterFailedBind() { + runCatching { closeChannel(localChannel) } + localChannel = null + shutdownEventLoop(directEventLoop) + shutdownEventLoop(localEventLoop) + } + + 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_INITIALIZER, initializer) + pipeline.fireChannelRead(message) + } + } + + companion object { + fun bind(friendServer: FriendControlServer): + ShareConnectionGateway = + ShareConnectionGateway(friendServer) + + 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_INITIALIZER = + "connect-share-minecraft-initializer" + } +} 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 index 8f0a35bf5..c744ce344 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -16,11 +16,34 @@ import io.netty.util.concurrent.DefaultThreadFactory import java.net.InetSocketAddress import java.net.SocketAddress -open class VersionedMinecraftBridge( +open class VersionedMinecraftBridge private constructor( private val transport: MinecraftVersionTransport, - private val localBinder: LocalShareChannelBinder, + 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 @@ -30,26 +53,54 @@ open class VersionedMinecraftBridge( 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 = {}, ) - local = localBinder.bind(published.childInitializer) - validateLocal(local).fold( - ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, - ifRight = {}, - ) - published.addLocalListener(local) - localAdded = true + 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) + 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, local, admission) + val acquired = ActiveTransport( + published = published, + local = local, + localAdded = localAdded, + gatewayLease = gatewayLease, + admission = admission, + ) active = acquired - serverSocketAddress = local.address + serverSocketAddress = connectAddress LocalShareTarget( - address = local.address, - directAddress = published.address, + address = connectAddress, + directAddress = directAddress, ) { close(acquired) } @@ -58,6 +109,9 @@ open class VersionedMinecraftBridge( cleanup = releaseAfter(cleanup) { admission?.close() } + cleanup = releaseAfter(cleanup) { + gatewayLease?.close() + } if (localAdded) { cleanup = releaseAfter(cleanup) { published.removeLocalListener(checkNotNull(local)) @@ -115,13 +169,27 @@ open class VersionedMinecraftBridge( } } + 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 local: LocalShareChannel?, + private val localAdded: Boolean, + private val gatewayLease: AutoCloseable?, private val admission: AutoCloseable?, ) { private var admissionStopped = false - private var localClosed = false + private var routeClosed = false private var publishedClosed = false fun stopAdmission(primary: Throwable?): Throwable? { @@ -135,11 +203,17 @@ open class VersionedMinecraftBridge( } fun closeLocal(primary: Throwable?): Throwable? { - if (localClosed) { + if (routeClosed) { return primary } - localClosed = true + routeClosed = true var failure = releaseAfter(primary) { + gatewayLease?.close() + } + if (!localAdded || local == null) { + return failure + } + failure = releaseAfter(failure) { published.removeLocalListener(local) } failure = releaseAfter(failure) { 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 index 9bb1f98e7..bc4319b45 100644 --- 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 @@ -6,7 +6,6 @@ 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.ChannelHandler import io.netty.channel.ChannelHandlerContext import io.netty.channel.ChannelInboundHandlerAdapter import io.netty.util.ReferenceCountUtil @@ -169,19 +168,3 @@ class FriendControlChannelHandler( ) } } - -object FriendControlChannelRegistry { - private val installed = AtomicReference() - - fun install(server: FriendControlServer): AutoCloseable { - check(installed.compareAndSet(null, server)) { - "A friend control server is already installed" - } - return AutoCloseable { - installed.compareAndSet(server, null) - } - } - - fun createHandler(): ChannelHandler? = - installed.get()?.let(::FriendControlChannelHandler) -} 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..71593ff55 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt @@ -0,0 +1,182 @@ +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.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), + ) + } + }, + ) + } + } + 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 + } + } + + private companion object { + val CONTROL_REQUEST = com.minekube.connect.share.friend + .FriendControlRequest( + requestId = java.util.UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + displayName = "ordinary", + invitation = "minekube://share/ordinary", + ) + val MINECRAFT_BYTES = + com.minekube.connect.share.friend.FriendControlWire + .encodeRequest( + protocolVersion = 1_075, + serverAddress = "ordinary-minecraft", + request = CONTROL_REQUEST, + ).copyOf().also { bytes -> + val port = + com.minekube.connect.share.friend + .FriendControlWire + .CONTROL_HANDSHAKE_PORT + val high = port ushr 8 + val low = port and 0xff + val index = bytes.indices.first { + it + 1 < bytes.size && + bytes[it].toInt() and 0xff == high && + bytes[it + 1].toInt() and 0xff == low + } + bytes[index] = (25_565 ushr 8).toByte() + bytes[index + 1] = 25_565.toByte() + } + } +} 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..11e144686 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -0,0 +1,315 @@ +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.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +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 java.io.ByteArrayOutputStream +import java.net.Socket +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ShareConnectionGatewayTest { + @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( + protocolVersion = 1_075, + serverAddress = "connect-share", + 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 world = 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 + 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), + ) + } + + 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( + protocolVersion = 1_075, + serverAddress = "friend-control", + 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 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 = + FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "ordinary-minecraft", + request = REQUEST, + ).copyOf().also { bytes -> + val controlHigh = + FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 + val controlLow = + FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff + val portIndex = bytes.indices.first { + it + 1 < bytes.size && + bytes[it].toInt() and 0xff == controlHigh && + bytes[it + 1].toInt() and 0xff == controlLow + } + bytes[portIndex] = (25_565 ushr 8).toByte() + bytes[portIndex + 1] = 25_565.toByte() + } + } +} 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 index 6237e7c3d..ca606795b 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -10,15 +11,25 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardReceiver import com.minekube.connect.share.fabric.FriendOnlineTracker import com.minekube.connect.share.fabric.FriendPresenceMonitor -import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.MinecraftStatusProbe import com.minekube.connect.share.friend.FriendStore +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.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 @@ -32,87 +43,149 @@ import net.minecraft.network.chat.Component class ConnectShare12111Client : ClientModInitializer { override fun onInitializeClient() { val client = Minecraft.getInstance() - val dispatcher = client.asCoroutineDispatcher() - val scope = CoroutineScope(SupervisorJob() + dispatcher) + 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 minecraftVersion = + SharedConstants.getCurrentVersion().name() + val minecraftProtocolVersion = + SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) - val remotePresence = FriendPresenceMonitor(friendStore) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + probe = statusProbe, + directProbe = { friend -> + browserReference.get()?.probeLan( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ownConnectAddress = + ConnectShareClient::connectPublicAddress, + ) scope.launch { while (isActive) { remotePresence.refresh() delay(PRESENCE_REFRESH_MILLIS) } } - val installation = FabricShareBootstrap.create( - scope = scope, - dataDirectory = dataDirectory, - minecraftVersion = SharedConstants.getCurrentVersion().name(), - minecraftProtocolVersion = SharedConstants.getProtocolVersion(), - worldAvailable = client.hasSingleplayerServer(), - friendStore = friendStore, - playerCount = { - client.singleplayerServer?.playerList?.playerCount ?: 0 - }, - worldDisplayName = { - client.singleplayerServer?.worldData?.levelName - ?: "Minecraft world" - }, - bridgeFactory = { admission, admissionScope, approvedJoins -> - Minecraft12111Bridge { - FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission( + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + minecraftProtocolVersion = minecraftProtocolVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + bridgeFactory = { admission, + admissionScope, approvedJoins, - ), - scope = admissionScope, - ) - } - }, - screens = { parent, active -> - val parentScreen = parent as Screen - client.execute { - client.setScreen( - if (active) { - ShareStatusScreen(parentScreen) - } else { - ShareSetupScreen(parentScreen) - }, + 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 -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, ) - } - }, - guestScreens = { parent -> - val parentScreen = parent as Screen - client.execute { - client.setScreen( - ShareJoinScreen( - parent = parentScreen, - friends = FriendsViewModel( - friendStore, - ), - browser = FabricShareBrowser(dataDirectory), - remotePresence = remotePresence, - ), + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", ) } - }, - ) - FriendCardNetworking.install( - scope = scope, - issuer = installation.friendCardIssuer, - receiver = installation.friendCardReceiver, - approvedJoins = installation.approvedJoins, - ) - ConnectShareClient.install(installation) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } val admissionNotifications = NewAdmissionTracker() val friendNotifications = FriendOnlineTracker() val admissionToastId = SystemToast.SystemToastId() val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + val installation = + installationReference.get() + ?: return@register + val server = minecraft.singleplayerServer + val worldAvailable = minecraft.hasSingleplayerServer() + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) ConnectShareClient.integratedWorldChanged( - minecraft.hasSingleplayerServer(), - minecraft.singleplayerServer, + worldAvailable, + server, ) ConnectShareClient.guestConnectionChanged( minecraft.connection != null, @@ -157,12 +230,21 @@ class ConnectShare12111Client : ClientModInitializer { } } ClientLifecycleEvents.CLIENT_STOPPING.register { - ConnectShareClient.shutdown() - scope.cancel() + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } } } private companion object { const val PRESENCE_REFRESH_MILLIS = 30_000L + val LOGGER: Logger = Logger.getLogger("Connect") } } 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 index 6b4e7cc23..49efb333c 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareCha 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 @@ -39,6 +40,19 @@ class Minecraft12111Bridge internal constructor( ) } +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 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 index 879901f31..ed76f443d 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor 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.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel @@ -55,7 +56,6 @@ class ShareJoinScreen( private var joining = false private var joiningPeerId: String? = null private var reciprocalPairing = false - private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE private var requestOperationInProgress = false @@ -72,6 +72,9 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) fingerprint = currentFingerprint() nameBox = null invitationBox = null @@ -89,6 +92,9 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) val next = currentFingerprint() if (next != fingerprint) { rebuildWidgets() @@ -119,9 +125,6 @@ class ShareJoinScreen( override fun removed() { scope?.cancel() scope = null - if (!transferred) { - browser.close() - } super.removed() } @@ -140,11 +143,16 @@ class ShareJoinScreen( ) val state = friends.state.value - val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val incoming = state.incomingRequests.take( + MAX_VISIBLE_RELATIONSHIPS, + ) + val outgoing = state.outgoingRequests.take( + MAX_VISIBLE_RELATIONSHIPS - incoming.size, + ) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - outgoing.size, + MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, ) - if (outgoing.isEmpty() && saved.isEmpty()) { + if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -152,8 +160,39 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - outgoing.forEachIndexed { index, request -> + incoming.forEachIndexed { index, request -> val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.incoming_request", + request.displayName, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + outgoing.forEachIndexed { index, request -> + val y = 58 + (incoming.size + index) * 26 val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( @@ -192,7 +231,8 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (outgoing.size + index) * 26 + val y = + 58 + (incoming.size + outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -569,6 +609,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ).fold( ifLeft = ::joinFailed, ifRight = ::connect, @@ -633,6 +675,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { @@ -771,10 +815,7 @@ class ShareJoinScreen( ) } if (target is GuestJoinTarget.Direct) { - ConnectShareClient.holdGuestDirect(target, browser) - transferred = true - } else { - browser.close() + ConnectShareClient.holdGuestDirect(target) } val state = friends.state.value val joiningFriend = state.friends.firstOrNull { @@ -849,6 +890,12 @@ class ShareJoinScreen( ) } + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + private fun outgoingRequestLabel( displayName: String, deliveryState: RequestDeliveryState?, 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 index c9feb35ec..e85b94ae9 100644 --- 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 @@ -53,17 +53,18 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder sein Connect-Endpunkt ist nicht 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.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", + "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", 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 index 1f1649285..fe750872d 100644 --- 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 @@ -53,17 +53,18 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or their Connect endpoint unavailable.", "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", 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 index 589eca7b3..84535e097 100644 --- 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 @@ -46,6 +46,10 @@ class Fabric12111ArtifactTest { "\"connect_share.friends.outgoing_request\": " + "\"Request to %s\"" in language, ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %s\"" in language, + ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in language, @@ -86,6 +90,9 @@ class Fabric12111ArtifactTest { ) assertTrue("sendRequest" 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) } 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 index 2b43f0276..d4a6fada3 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -10,15 +11,25 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardReceiver import com.minekube.connect.share.fabric.FriendOnlineTracker import com.minekube.connect.share.fabric.FriendPresenceMonitor -import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.MinecraftStatusProbe import com.minekube.connect.share.friend.FriendStore +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.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 @@ -32,88 +43,149 @@ import net.minecraft.network.chat.Component class ConnectShare262Client : ClientModInitializer { override fun onInitializeClient() { val client = Minecraft.getInstance() + val clientDispatcher = client.asCoroutineDispatcher() val scope = CoroutineScope( - SupervisorJob() + client.asCoroutineDispatcher(), + 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 minecraftVersion = + SharedConstants.getCurrentVersion().name() + val minecraftProtocolVersion = + SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) - val remotePresence = FriendPresenceMonitor(friendStore) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + probe = statusProbe, + directProbe = { friend -> + browserReference.get()?.probeLan( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ownConnectAddress = + ConnectShareClient::connectPublicAddress, + ) scope.launch { while (isActive) { remotePresence.refresh() delay(PRESENCE_REFRESH_MILLIS) } } - val installation = FabricShareBootstrap.create( - scope = scope, - dataDirectory = dataDirectory, - minecraftVersion = SharedConstants.getCurrentVersion().name(), - minecraftProtocolVersion = SharedConstants.getProtocolVersion(), - worldAvailable = client.hasSingleplayerServer(), - friendStore = friendStore, - playerCount = { - client.singleplayerServer?.playerList?.playerCount ?: 0 - }, - worldDisplayName = { - client.singleplayerServer?.worldData?.levelName - ?: "Minecraft world" - }, - bridgeFactory = { admission, admissionScope, approvedJoins -> - Minecraft262Bridge { - FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission( + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + minecraftProtocolVersion = minecraftProtocolVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + bridgeFactory = { admission, + admissionScope, approvedJoins, - ), - scope = admissionScope, + 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 -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, ) - } - }, - screens = { parent, active -> - val parentScreen = parent as Screen - client.execute { - client.gui.setScreen( - if (active) { - ShareStatusScreen(parentScreen) - } else { - ShareSetupScreen(parentScreen) - }, + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", ) } - }, - guestScreens = { parent -> - val parentScreen = parent as Screen - client.execute { - client.gui.setScreen( - ShareJoinScreen( - parent = parentScreen, - friends = FriendsViewModel( - friendStore, - ), - browser = FabricShareBrowser(dataDirectory), - remotePresence = remotePresence, - ), - ) - } - }, - ) - FriendCardNetworking.install( - scope = scope, - issuer = installation.friendCardIssuer, - receiver = installation.friendCardReceiver, - approvedJoins = installation.approvedJoins, - ) - ConnectShareClient.install(installation) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } val admissionNotifications = NewAdmissionTracker() val friendNotifications = FriendOnlineTracker() val admissionToastId = SystemToast.SystemToastId() val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + val installation = + installationReference.get() + ?: return@register + val server = minecraft.singleplayerServer + val worldAvailable = minecraft.hasSingleplayerServer() + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) ConnectShareClient.integratedWorldChanged( - minecraft.hasSingleplayerServer(), - minecraft.singleplayerServer, + worldAvailable, + server, ) ConnectShareClient.guestConnectionChanged( minecraft.connection != null, @@ -158,12 +230,21 @@ class ConnectShare262Client : ClientModInitializer { } } ClientLifecycleEvents.CLIENT_STOPPING.register { - ConnectShareClient.shutdown() - scope.cancel() + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } } } private companion object { const val PRESENCE_REFRESH_MILLIS = 30_000L + val LOGGER: Logger = Logger.getLogger("Connect") } } 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 index 427ebe151..91b054fa0 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareCha 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 @@ -36,6 +37,19 @@ class Minecraft262Bridge internal constructor( ) } +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 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 index d109173a3..33c5d796a 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor 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.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel @@ -55,7 +56,6 @@ class ShareJoinScreen( private var joining = false private var joiningPeerId: String? = null private var reciprocalPairing = false - private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE private var requestOperationInProgress = false @@ -72,6 +72,9 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) fingerprint = currentFingerprint() nameBox = null invitationBox = null @@ -89,6 +92,9 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) val next = currentFingerprint() if (next != fingerprint) { rebuildWidgets() @@ -119,9 +125,6 @@ class ShareJoinScreen( override fun removed() { scope?.cancel() scope = null - if (!transferred) { - browser.close() - } super.removed() } @@ -140,11 +143,16 @@ class ShareJoinScreen( ) val state = friends.state.value - val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val incoming = state.incomingRequests.take( + MAX_VISIBLE_RELATIONSHIPS, + ) + val outgoing = state.outgoingRequests.take( + MAX_VISIBLE_RELATIONSHIPS - incoming.size, + ) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - outgoing.size, + MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, ) - if (outgoing.isEmpty() && saved.isEmpty()) { + if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -152,8 +160,39 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - outgoing.forEachIndexed { index, request -> + incoming.forEachIndexed { index, request -> val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.incoming_request", + request.displayName, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + outgoing.forEachIndexed { index, request -> + val y = 58 + (incoming.size + index) * 26 val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( @@ -192,7 +231,8 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (outgoing.size + index) * 26 + val y = + 58 + (incoming.size + outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -569,6 +609,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ).fold( ifLeft = ::joinFailed, ifRight = ::connect, @@ -633,6 +675,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { @@ -770,10 +814,7 @@ class ShareJoinScreen( ) } if (target is GuestJoinTarget.Direct) { - ConnectShareClient.holdGuestDirect(target, browser) - transferred = true - } else { - browser.close() + ConnectShareClient.holdGuestDirect(target) } val state = friends.state.value val joiningFriend = state.friends.firstOrNull { @@ -848,6 +889,12 @@ class ShareJoinScreen( ) } + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + private fun outgoingRequestLabel( displayName: String, deliveryState: RequestDeliveryState?, 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 index c9feb35ec..e85b94ae9 100644 --- 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 @@ -53,17 +53,18 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder sein Connect-Endpunkt ist nicht 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.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", + "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", 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 index 1f1649285..fe750872d 100644 --- 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 @@ -53,17 +53,18 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or their Connect endpoint unavailable.", "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", 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 index 819fb224b..6fe5b64ac 100644 --- 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 @@ -46,6 +46,10 @@ class Fabric262ArtifactTest { "\"connect_share.friends.outgoing_request\": " + "\"Request to %s\"" in language, ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %s\"" in language, + ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in language, @@ -88,6 +92,9 @@ class Fabric262ArtifactTest { ) assertTrue("sendRequest" 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) } 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..a92886171 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt @@ -0,0 +1,56 @@ +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() + } + } +} 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 index a9f13c2e9..5cb772d48 100644 --- 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 @@ -1,24 +1,32 @@ 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 fun interface ConnectShareScreenFactory { fun open(parent: Any, active: Boolean) } fun interface ConnectShareGuestScreenFactory { - fun open(parent: Any) + fun open(parent: Any, browser: FabricShareBrowser) } data class ConnectShareInstallation( val viewModel: ShareViewModel, + val friendsViewModel: FriendsViewModel, val runtime: ConnectShareRuntime, val friendCardIssuer: FriendCardIssuer, val friendCardReceiver: FriendCardReceiver, val friendRequestClient: FriendRequestClient, + val friendPairingClient: FriendPairingClient, val approvedJoins: ApprovedJoinTracker, - val friendControlLease: AutoCloseable, + val controlPlane: ConnectControlPlane, + val directControlPlane: DirectControlPlane, + val browser: FabricShareBrowser, + val gateway: ShareConnectionGateway, + val ownConnectAddress: String, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -56,14 +64,15 @@ object ConnectShareClient { @JvmStatic fun openJoinScreen(parent: Any) { - installation?.guestScreens?.open(parent) + installation?.let { installed -> + installed.guestScreens.open(parent, installed.browser) + } } fun holdGuestDirect( target: GuestJoinTarget.Direct, - browser: FabricShareBrowser, ) { - guestLease.hold(target, browser) + guestLease.hold(target, NOOP_CLOSE) } @JvmStatic @@ -75,6 +84,10 @@ object ConnectShareClient { fun viewModel(): ShareViewModel = checkNotNull(installation).viewModel + @JvmStatic + fun friendsViewModel(): FriendsViewModel = + checkNotNull(installation).friendsViewModel + @JvmStatic fun friendCardIssuer(): FriendCardIssuer = checkNotNull(installation).friendCardIssuer @@ -87,6 +100,14 @@ object ConnectShareClient { fun friendRequestClient(): FriendRequestClient = checkNotNull(installation).friendRequestClient + @JvmStatic + fun friendPairingClient(): FriendPairingClient = + checkNotNull(installation).friendPairingClient + + @JvmStatic + fun connectPublicAddress(): String? = + installation?.ownConnectAddress + @JvmStatic fun armFriendCardExchange(peerId: String) { friendCardConsent.arm(peerId) @@ -105,13 +126,17 @@ object ConnectShareClient { } @JvmStatic - fun shutdown() { + suspend fun shutdown() { friendCardConsent.cancel() guestLease.close() installation?.let { installed -> - installed.friendControlLease.close() installed.runtime.shutdown() + installed.directControlPlane.shutdown() + installed.controlPlane.shutdown() + installed.browser.close() + installed.gateway.close() } + installation = null } private fun isShareActive(): Boolean = when ( @@ -127,6 +152,8 @@ object ConnectShareClient { ShareState.Stopping, -> true } + + private val NOOP_CLOSE = AutoCloseable {} } internal class GuestConnectionLease( 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 index df8084167..0a360d666 100644 --- 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 @@ -1,16 +1,19 @@ package com.minekube.connect.share.fabric +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart +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() @@ -37,7 +40,7 @@ class ConnectShareRuntime( worldAvailabilityChanged(worldAvailable) return } - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(lifecycleDispatcher) { lifecycle.withLock { if (transition.stopPrevious) { stopShare() @@ -50,15 +53,15 @@ class ConnectShareRuntime( } } - fun shutdown() { + suspend fun shutdown() { val shouldStop = synchronized(lock) { (currentWorldIdentity != null).also { currentWorldIdentity = null } } - worldAvailabilityChanged(false) - if (shouldStop) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { + withContext(lifecycleDispatcher) { + worldAvailabilityChanged(false) + if (shouldStop) { lifecycle.withLock { stopShare() } 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..d742f549a --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt @@ -0,0 +1,61 @@ +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() + } + } +} 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 index 07d36bf94..f6b7d9647 100644 --- 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 @@ -35,6 +35,7 @@ class FabricConnectIngress private constructor( private val admission: AdmissionController, private val approvedJoins: ApprovedJoinTracker, private val scope: CoroutineScope, + private val worldAvailable: () -> Boolean, private val runtimeFactory: FabricConnectRuntimeFactory, ) : ConnectShareIngress { constructor( @@ -45,11 +46,13 @@ class FabricConnectIngress private constructor( 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, @@ -79,6 +82,7 @@ class FabricConnectIngress private constructor( admission, scope, approvedJoins, + worldAvailable, ) val runtime = try { runtimeFactory.start(identity, target, gate) @@ -107,11 +111,13 @@ class FabricConnectIngress private constructor( runtimeFactory: FabricConnectRuntimeFactory, approvedJoins: ApprovedJoinTracker = ApprovedJoinTracker(), + worldAvailable: () -> Boolean = { true }, ) = FabricConnectIngress( dataDirectory = dataDirectory, admission = admission, approvedJoins = approvedJoins, scope = scope, + worldAvailable = worldAvailable, runtimeFactory = runtimeFactory, ) } 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 index 7637b0cb5..a3176ce52 100644 --- 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 @@ -27,6 +27,7 @@ class FabricSessionAdmissionGate( private val scope: CoroutineScope, private val approvedJoins: ApprovedJoinTracker = ApprovedJoinTracker(), + private val worldAvailable: () -> Boolean = { true }, ) : SessionAdmissionGate { private val stopped = AtomicBoolean() private val active = ConcurrentHashMap, Job>() @@ -39,6 +40,11 @@ class FabricSessionAdmissionGate( SessionAdmissionDecision.allow(), ) } + if (!worldAvailable()) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.deny(NO_SHARED_WORLD), + ) + } if (proposal.session.auth.passthrough) { return CompletableFuture.completedFuture( SessionAdmissionDecision.deferToLocalLogin(), @@ -137,6 +143,7 @@ class FabricSessionAdmissionGate( 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" } } 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 index a95e58d88..f41a07608 100644 --- 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 @@ -3,13 +3,16 @@ 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.VersionedMinecraftBridge import com.minekube.connect.share.admission.AdmissionController import com.minekube.connect.share.admission.AdmissionIdentity 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.friend.FriendStore -import com.minekube.connect.share.friend.FriendControlChannelRegistry import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -25,7 +28,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient object FabricShareBootstrap { - fun create( + suspend fun create( scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, @@ -39,6 +42,7 @@ object FabricShareBootstrap { AdmissionController, CoroutineScope, ApprovedJoinTracker, + ShareConnectionGateway, ) -> VersionedMinecraftBridge, screens: ConnectShareScreenFactory, guestScreens: ConnectShareGuestScreenFactory, @@ -72,11 +76,6 @@ object FabricShareBootstrap { }.getOrDefault(false) }, ) - val bridge = bridgeFactory( - admission, - scope, - approvedJoins, - ) val identityStore = EndpointIdentityStore( directory = dataDirectory, environment = environment, @@ -95,63 +94,14 @@ object FabricShareBootstrap { watchUrl = watchHttpUrl(environment), timeout = 10.seconds, ) - val ingress = FabricConnectIngress( - dataDirectory = dataDirectory, - platformInjector = bridge, - logger = logger, - platformUtils = FabricPlatformUtils( - minecraftVersion = minecraftVersion, - playerCount = playerCount, - ), - admission = admission, - approvedJoins = approvedJoins, - scope = scope, - ) - val directIngress = FabricDirectShareIngress( - dataDirectory = dataDirectory, - displayName = worldDisplayName, - ) - val coordinator = ShareCoordinator( - bridge = bridge, - ingress = ingress, - identityProvider = identityStore::currentOrCreate, - admission = admission, - directIngress = directIngress, - failureReporter = logger::warn, - ) - val viewModel = ShareViewModel( - scope = scope, - shareState = coordinator.state, - pendingAdmissions = admission.pending, - initialWorldAvailable = worldAvailable, - initialShareWithFriendsEnabled = - initialPreferences.shareWithFriends, - persistShareWithFriendsEnabled = { enabled -> - preferencesStore.save( - SharePreferences(shareWithFriends = enabled), - ) - }, - identityActions = StoredEndpointIdentityUiActions( - store = identityStore, - validator = validator, - ), - startShare = coordinator::start, - stopShare = coordinator::stop, - answerAdmission = admission::answer, - ) - viewModelReference.set(viewModel) - val runtime = ConnectShareRuntime( - scope = scope, - stopShare = { - coordinator.worldReplaced() - }, - resumeShare = viewModel::resumeIfEnabled, - worldAvailabilityChanged = viewModel::setWorldAvailable, - ) + val endpointIdentity = identityStore.currentOrCreate() + val ownConnectAddress = + "${endpointIdentity.endpoint}.play.minekube.net" val friendCardIssuer = FriendCardIssuer(dataDirectory) { - "${identityStore.currentOrCreate().endpoint}.play.minekube.net" + ownConnectAddress } val friendCardReceiver = FriendCardReceiver(friendStore) + val friendsViewModel = FriendsViewModel(friendStore) val friendRequestServer = FriendRequestServer( scope = scope, admission = admission, @@ -159,21 +109,128 @@ object FabricShareBootstrap { receiver = friendCardReceiver, friendStore = friendStore, ) - val friendControlLease = - FriendControlChannelRegistry.install(friendRequestServer) - return ConnectShareInstallation( - viewModel = viewModel, - runtime = runtime, - friendCardIssuer = friendCardIssuer, - friendCardReceiver = friendCardReceiver, - friendRequestClient = FriendRequestClient( + val gateway = ShareConnectionGateway.bind(friendRequestServer) + var browser: FabricShareBrowser? = null + try { + val activeBrowser = FabricShareBrowser(dataDirectory) + 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( + FabricDirectShareIngress( + dataDirectory = dataDirectory, + displayName = worldDisplayName, + ), + ) + val coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityStore::currentOrCreate, + admission = admission, + directIngress = directIngress, + failureReporter = logger::warn, + ) + val viewModel = ShareViewModel( + scope = scope, + shareState = coordinator.state, + pendingAdmissions = admission.pending, + initialWorldAvailable = worldAvailable, + initialShareWithFriendsEnabled = + initialPreferences.shareWithFriends, + persistShareWithFriendsEnabled = { enabled -> + preferencesStore.save( + SharePreferences(shareWithFriends = enabled), + ) + }, + identityActions = StoredEndpointIdentityUiActions( + store = identityStore, + validator = validator, + ), + startShare = coordinator::start, + stopShare = coordinator::stop, + answerAdmission = admission::answer, + ) + viewModelReference.set(viewModel) + val runtime = ConnectShareRuntime( + scope = scope, + stopShare = { + coordinator.worldReplaced() + }, + resumeShare = viewModel::resumeIfEnabled, + worldAvailabilityChanged = viewModel::setWorldAvailable, + ) + val friendRequestClient = FriendRequestClient( minecraftProtocolVersion, - ), - approvedJoins = approvedJoins, - friendControlLease = friendControlLease, - screens = screens, - guestScreens = guestScreens, - ) + ) + val friendPairingClient = FriendPairingClient( + store = friendStore, + issuer = friendCardIssuer, + receiver = friendCardReceiver, + requestClient = friendRequestClient, + ) + val controlPlane = ConnectControlPlane( + scope = scope, + ingress = ingress, + identity = { endpointIdentity }, + target = gateway.serverSocketAddress, + failureReporter = logger::warn, + ).also(ConnectControlPlane::start) + val directControlPlane = DirectControlPlane( + scope = scope, + ingress = directIngress, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ), + target = gateway.directAddress, + connectAddress = { ownConnectAddress }, + failureReporter = logger::warn, + ).also(DirectControlPlane::start) + return ConnectShareInstallation( + viewModel = viewModel, + friendsViewModel = friendsViewModel, + runtime = runtime, + friendCardIssuer = friendCardIssuer, + friendCardReceiver = friendCardReceiver, + friendRequestClient = friendRequestClient, + friendPairingClient = friendPairingClient, + approvedJoins = approvedJoins, + controlPlane = controlPlane, + directControlPlane = directControlPlane, + browser = activeBrowser, + gateway = gateway, + ownConnectAddress = ownConnectAddress, + screens = screens, + guestScreens = guestScreens, + ) + } catch (failure: Throwable) { + browser?.close() + gateway.close() + throw failure + } } internal fun watchHttpUrl(environment: Map) = 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 index 14affbdcd..3ec45f453 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -86,17 +87,24 @@ sealed interface GuestJoinFailure { 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( @@ -105,6 +113,7 @@ class FabricShareBrowser private constructor( ), now = Instant::now, ioDispatcher = Dispatchers.IO, + routeReporter = LOGGER::info, ) private val mutableDiscovered = @@ -159,29 +168,46 @@ class FabricShareBrowser private constructor( when (route) { ShareRoute.DIRECT_LAN -> { val address = effectiveLanAddress ?: continue - openDirect( + val direct = openDirect( route, address, invitation, authMode, LAN_TIMEOUT, - )?.let { return@withContext it.right() } + ) + 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) { - openDirect( + attempted = true + val direct = openDirect( route, address, invitation, authMode, INTERNET_TIMEOUT, - )?.let { return@withContext it.right() } + ) + 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() } } @@ -194,24 +220,63 @@ class FabricShareBrowser private constructor( suspend fun join( friend: SavedFriend, authMode: DirectP2pAuthMode, + ownConnectAddress: String? = null, ): Either = withContext(ioDispatcher) { - matchingLanShare(friend)?.let { discovered -> - openDirect( + 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, - )?.let { return@withContext it.right() } + ) + if (direct != null) { + reportRoute(ROUTE_DIRECT_LAN) + return@withContext direct.right() + } + reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) + } else { + reportRoute(ROUTE_DIRECT_LAN_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 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() + } + } + override fun close() { if (closed.compareAndSet(false, true)) { node.close() @@ -301,20 +366,73 @@ class FabricShareBrowser private constructor( 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, - ) = FabricShareBrowser(node, now, ioDispatcher) + 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 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..236f1fca1 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt @@ -0,0 +1,88 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.raise.either +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() + val hostCard = requestClient.exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + displayName = senderDisplayName, + invitation = senderCard, + ), + onReceived = onReceived, + ).mapLeft(FriendPairingFailure::Delivery).bind() + receiver.receive( + invitation = hostCard, + displayName = friendDisplayName, + authenticatedMinecraftUuid = null, + 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 index c82545422..c3d7a6cba 100644 --- 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 @@ -1,12 +1,16 @@ 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, @@ -14,6 +18,7 @@ data class RemoteFriendPresence( val online: Boolean, val description: String? = null, val notifyWhenOnline: Boolean, + val route: ShareRoute? = null, ) class FriendOnlineTracker { @@ -37,13 +42,22 @@ class FriendOnlineTracker { class FriendPresenceMonitor private constructor( private val friends: () -> List, private val probe: FriendStatusProbe, + private val directProbe: suspend (SavedFriend) -> ServerPresence?, + private val ownConnectAddress: () -> String?, + private val ioDispatcher: CoroutineDispatcher, ) { constructor( store: FriendStore, probe: FriendStatusProbe = MinecraftStatusProbe(), + directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, + ownConnectAddress: () -> String? = { null }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : this( friends = store::all, probe = probe, + directProbe = directProbe, + ownConnectAddress = ownConnectAddress, + ioDispatcher = ioDispatcher, ) private val mutableState = @@ -52,18 +66,34 @@ class FriendPresenceMonitor private constructor( val state: StateFlow> = mutableState.asStateFlow() - suspend fun refresh() { + suspend fun refresh() = withContext(ioDispatcher) { val saved = runCatching(friends) .getOrDefault(emptyList()) .take(MAX_PROBED_FRIENDS) + val ownAddress = runCatching(ownConnectAddress).getOrNull() val results = saved.parMap( - context = Dispatchers.IO, + context = ioDispatcher, concurrency = MAX_CONCURRENT_PROBES, ) { friend -> - val result = friend.connectAddress?.let { - probe.probe(it) + val directPresence = try { + directProbe(friend) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null } - val presence = result?.getOrNull() + val connectPresence = if (directPresence == null) { + friend.connectAddress?.let { address -> + if (connectAddressesMatch(address, ownAddress)) { + null + } else { + probe.probe(address).getOrNull() + } + } + } else { + null + } + val presence = directPresence ?: connectPresence friend.peerId to RemoteFriendPresence( peerId = friend.peerId, displayName = friend.displayName, @@ -71,6 +101,11 @@ class FriendPresenceMonitor private constructor( description = presence?.description, notifyWhenOnline = friend.permissions.notifyWhenOnline, + route = when { + directPresence != null -> ShareRoute.DIRECT_LAN + connectPresence != null -> ShareRoute.CONNECT + else -> null + }, ) } mutableState.value = results.toMap() @@ -80,7 +115,18 @@ class FriendPresenceMonitor private constructor( internal fun testing( friends: () -> List, probe: FriendStatusProbe, - ) = FriendPresenceMonitor(friends, probe) + directProbe: suspend (SavedFriend) -> ServerPresence? = { + null + }, + ownConnectAddress: () -> String? = { null }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ) = FriendPresenceMonitor( + friends, + probe, + directProbe, + ownConnectAddress, + 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/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt index 65a0b2d32..f46c8cc93 100644 --- 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 @@ -29,6 +29,7 @@ class FriendRequestServer( private val friendStore: FriendStore, private val now: () -> Instant = Instant::now, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val onRelationshipChanged: () -> Unit = {}, ) : FriendControlServer { override fun handle( context: FriendControlContext, @@ -100,6 +101,7 @@ class FriendRequestServer( if (received.isLeft()) { FriendControlResponse.Invalid } else { + notifyRelationshipChanged() issueHostCard(instant) } } @@ -120,6 +122,14 @@ class FriendRequestServer( 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, ) { 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..9fae32759 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt @@ -0,0 +1,147 @@ +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 + } + } + } + + 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..875c01e00 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt @@ -0,0 +1,158 @@ +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.sync.Mutex +import kotlinx.coroutines.sync.withLock + +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 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 -> { + mutableState.value = PersistentDirectState.Starting + 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() + } + } + } + } + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle = startControl( + 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 = {}) + } + } +} 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 index 3f1d9281c..059b07ad9 100644 --- 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 @@ -2,17 +2,22 @@ package com.minekube.connect.share.fabric.ui import arrow.core.Either import arrow.core.left +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.direct.ShareRoute 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.tunnel.p2p.DirectP2pAuthMode import java.time.Instant -import java.util.Base64 +import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -32,9 +37,16 @@ data class OutgoingFriendRequestSummary( val displayName: String, ) +data class IncomingFriendRequestSummary( + val requestId: UUID, + val displayName: String, + val ingress: Ingress, +) + data class FriendsUiState( val friends: List = emptyList(), val outgoingRequests: List = emptyList(), + val incomingRequests: List = emptyList(), val safeMessage: String? = null, ) @@ -43,6 +55,8 @@ class FriendsViewModel( ) { private var discovered: List = emptyList() private var remotePresence: Map = emptyMap() + private var incomingRequests: List = + emptyList() private val mutableState = MutableStateFlow(loadInitialState()) val state: StateFlow = mutableState.asStateFlow() @@ -120,24 +134,52 @@ class FriendsViewModel( refresh(preserveSafeMessage = true) } + fun updateIncoming(pending: List) { + val next = pending + .asSequence() + .filter { it.purpose == AdmissionPurpose.FRIEND } + .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 + }, + ) + } + .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) + return browser.join(friend, authMode, ownConnectAddress) } suspend fun routeOutgoing( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, + ownConnectAddress: String? = null, ): Either { val request = outgoingRequest(peerId) ?: return GuestJoinFailure.NoRoute.left() - return browser.join(request, authMode) + return browser.join(request, authMode, ownConnectAddress) } fun reload() { @@ -192,6 +234,7 @@ class FriendsViewModel( displayName = it.displayName, ) }, + incomingRequests = incomingRequests, ) private fun update(transform: FriendsUiState.() -> FriendsUiState) { @@ -199,13 +242,6 @@ class FriendsViewModel( } private fun SavedFriend.summary(): FriendSummary { - val presence = discovered.firstOrNull { - val invitation = it.invitation - invitation.payload.peerId == peerId && - invitation.payload.shareId == shareId && - Base64.getEncoder().encodeToString(invitation.publicKey) == - publicKeyBase64 - } val remote = remotePresence[peerId] ?.takeIf { it.online } return FriendSummary( @@ -213,9 +249,9 @@ class FriendsViewModel( displayName = displayName, connectAvailable = connectAddress != null, permissions = permissions, - onlineViaLan = presence != null, - onlineViaConnect = remote != null, - worldName = presence?.displayName ?: remote?.description, + onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, + onlineViaConnect = remote?.route == ShareRoute.CONNECT, + worldName = remote?.description, ) } 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 index 1e7eb4d6c..d9a6cc44a 100644 --- 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 @@ -1,20 +1,44 @@ 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 = backgroundScope, + scope = this, stopShare = { stopCalls++ }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), ) runtime.integratedWorldChanged(worldAvailable = true) @@ -29,10 +53,12 @@ class ConnectShareRuntimeTest { fun `replacing an integrated world stops the previous share`() = runTest { var stopCalls = 0 val runtime = ConnectShareRuntime( - scope = backgroundScope, + scope = this, stopShare = { stopCalls++ }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), ) runtime.integratedWorldChanged(worldAvailable = true, identity = "one") @@ -46,13 +72,15 @@ class ConnectShareRuntimeTest { fun `enabled sharing resumes when the host enters or switches worlds`() = runTest { val lifecycle = mutableListOf() val runtime = ConnectShareRuntime( - scope = backgroundScope, + scope = this, stopShare = { lifecycle += "stop" }, resumeShare = { lifecycle += "resume" }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), ) runtime.integratedWorldChanged(worldAvailable = true, identity = "one") 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..b23d97a9a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt @@ -0,0 +1,134 @@ +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.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 `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 + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + starts++ + 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/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt index a86286a05..a9a0f6fe9 100644 --- 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 @@ -23,6 +23,41 @@ 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() 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 index 3b128beb5..5f65efb48 100644 --- 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 @@ -147,7 +147,8 @@ class FabricShareBrowserTest { @Test fun `saved friend ignores LAN metadata signed by a different identity`() = runTest { val node = FakeGuestNode() - val browser = browser(node) + val reports = mutableListOf() + val browser = browser(node, reports::add) browser.start() val friend = savedFriend(invitation()) node.discover( @@ -166,9 +167,70 @@ class FabricShareBrowserTest { assertIs>(result) assertTrue(node.openedAddresses.isEmpty()) + assertEquals( + listOf( + "Connect Share route: direct LAN unavailable", + "Connect Share route: using Connect fallback", + ), + reports, + ) browser.close() } + @Test + fun `saved friend never falls back through this profiles own Connect endpoint`() = + runTest { + val node = FakeGuestNode() + 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(), + ) + assertTrue(node.openedAddresses.isEmpty()) + 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 `pasted invitation ignores discovery with a different peer`() = runTest { val node = FakeGuestNode() @@ -229,7 +291,8 @@ class FabricShareBrowserTest { fun `failed direct reachability falls back to Connect exactly once`() = runTest { val node = FakeGuestNode(failDirect = true) - val browser = browser(node) + val reports = mutableListOf() + val browser = browser(node, reports::add) val result = browser.join( invitationUri = invitation(), @@ -244,6 +307,14 @@ class FabricShareBrowserTest { 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() } @@ -265,11 +336,15 @@ class FabricShareBrowserTest { browser.close() } - private fun kotlinx.coroutines.test.TestScope.browser(node: FakeGuestNode) = + 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( 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..67a74cbad --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt @@ -0,0 +1,253 @@ +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.direct.DirectSessionRegistry +import com.minekube.connect.share.friend.FriendStore +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.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 hostServer = FriendRequestServer( + scope = this, + admission = admission, + issuer = FriendCardIssuer(hostDirectory) { + "host.play.minekube.net" + }, + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + + 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 pairing = FriendPairingClient( + store = senderStore, + issuer = FriendCardIssuer(senderDirectory) { + "sender.play.minekube.net" + }, + receiver = FriendCardReceiver(senderStore), + requestClient = FriendRequestClient( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + connectTimeout = Duration.ofSeconds(3), + decisionTimeout = Duration.ofSeconds(5), + ), + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + var received = false + 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 = true }, + ) + } + + val pending = withTimeout(5.seconds) { + admission.pending + .first { it.isNotEmpty() } + .single() + } + assertTrue(received) + admission.answer(pending.requestId, allow = true) + + assertTrue(result.await().isRight()) + assertEquals( + "bob", + hostStore.all().single().displayName, + ) + assertEquals( + "RoboFlax2", + senderStore.all().single().displayName, + ) + } 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) { + node.publish(invitation) + } + + 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..095d835dd --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt @@ -0,0 +1,138 @@ +package com.minekube.connect.share.fabric + +import arrow.core.right +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.friend.FriendRelationshipStatus +import com.minekube.connect.share.friend.FriendStore +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.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +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 FriendPairingE2ETest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `signed request accepted through title gateway persists mutual friendship`() = + runBlocking { + val now = Instant.parse("2026-07-31T10:30:00Z") + val hostDirectory = tempDir.resolve("host") + val senderDirectory = tempDir.resolve("sender") + val hostStore = FriendStore(hostDirectory) + val senderStore = FriendStore(senderDirectory) + val hostAddress = AtomicReference() + var hostRelationshipsChanged = 0 + val hostIssuer = FriendCardIssuer(hostDirectory) { + hostAddress.get() + } + val admission = AdmissionController( + scope = this, + timeout = 10.seconds, + maxPending = 8, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + val hostServer = FriendRequestServer( + scope = this, + admission = admission, + issuer = hostIssuer, + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { now }, + ioDispatcher = Dispatchers.IO, + onRelationshipChanged = { + hostRelationshipsChanged++ + }, + ) + ShareConnectionGateway.bind(hostServer).use { gateway -> + hostAddress.set( + "${gateway.directAddress.hostString}:" + + gateway.directAddress.port, + ) + val invitation = hostIssuer.issue(now).getOrNull()!! + val pairing = FriendPairingClient( + store = senderStore, + issuer = FriendCardIssuer(senderDirectory) { + "sender.play.minekube.net" + }, + receiver = FriendCardReceiver(senderStore), + requestClient = FriendRequestClient( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + connectTimeout = Duration.ofSeconds(2), + decisionTimeout = Duration.ofSeconds(5), + ), + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + var received = false + + val result = async { + pairing.send( + invitation = invitation, + friendDisplayName = "RoboFlax2", + senderDisplayName = "bob", + route = { saved -> + GuestJoinTarget.Connect( + checkNotNull(saved.connectAddress), + ).right() + }, + onReceived = { received = true }, + ) + } + + val pending = withTimeout(2.seconds) { + admission.pending.first { it.isNotEmpty() }.single() + } + assertTrue(received) + assertTrue(hostStore.all().isEmpty()) + assertEquals( + FriendRelationshipStatus.PENDING_OUTGOING, + senderStore.outgoingRequests() + .single() + .relationshipStatus, + ) + + admission.answer(pending.requestId, allow = true) + val accepted = result.await().getOrNull()!! + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + accepted.relationshipStatus, + ) + assertTrue(senderStore.outgoingRequests().isEmpty()) + assertEquals("RoboFlax2", senderStore.all().single().displayName) + assertEquals("bob", hostStore.all().single().displayName) + assertEquals(1, hostRelationshipsChanged) + assertTrue( + senderStore.all() + .single() + .permissions + .canJoinAutomatically, + ) + assertTrue( + hostStore.all() + .single() + .permissions + .canJoinAutomatically, + ) + assertFalse( + senderStore.all().single().peerId == + hostStore.all().single().peerId, + ) + } + } +} 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 index 64fe60cdd..413773ff8 100644 --- 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 @@ -1,16 +1,49 @@ package com.minekube.connect.share.fabric import arrow.core.Either +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() + }, + probe = FriendStatusProbe { + error("no friends should be probed") + }, + 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( @@ -44,6 +77,57 @@ class FriendPresenceMonitorTest { assertFalse(presence.toString().contains("capability-secret")) } + @Test + fun `direct LAN status is preferred before Connect presence`() = runTest { + val nearby = friend( + peerId = "12D3KooWNearby", + address = "nearby.play.minekube.net", + ) + val connectProbes = mutableListOf() + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(nearby) }, + directProbe = { + ServerPresence("Robin's LAN World") + }, + probe = FriendStatusProbe { address -> + connectProbes += address + Either.Right(ServerPresence("Wrong Connect 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) + assertTrue(connectProbes.isEmpty()) + } + + @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") + }, + probe = FriendStatusProbe { + Either.Right(ServerPresence("must not run")) + }, + ) + + assertFailsWith { + monitor.refresh() + } + } + @Test fun `online notification fires once per transition and respects preference`() { val tracker = FriendOnlineTracker() @@ -77,6 +161,29 @@ class FriendPresenceMonitorTest { ) } + @Test + fun `refresh never probes this profiles own Connect endpoint as a friend`() = + runTest { + val copied = friend( + peerId = "12D3KooWCopiedEndpoint", + address = "mine.play.minekube.net", + ) + val probed = mutableListOf() + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(copied) }, + ownConnectAddress = { "mine.play.minekube.net" }, + probe = FriendStatusProbe { address -> + probed += address + Either.Right(ServerPresence("Wrong self presence")) + }, + ) + + monitor.refresh() + + assertTrue(probed.isEmpty()) + assertFalse(monitor.state.value.getValue(copied.peerId).online) + } + private fun friend( peerId: String, address: String, 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..f445610f0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt @@ -0,0 +1,130 @@ +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() + } + + 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..333f4bff8 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt @@ -0,0 +1,158 @@ +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 `title startup and world leases share one direct host until shutdown`() = + 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, + TARGET, + CONNECT_ADDRESS, + ) + firstWorld.close() + secondWorld.close() + + assertEquals(0, delegate.closes.get()) + assertEquals(INVITATION, firstWorld.invitation) + assertTrue(firstWorld.lanAvailable) + + persistent.shutdown() + persistent.shutdown() + + assertEquals(1, 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.start( + CONTROL_OPTIONS, + InetSocketAddress(InetAddress.getLoopbackAddress(), 25_566), + CONNECT_ADDRESS, + ) + } + assertFailsWith { + persistent.start( + CONTROL_OPTIONS, + TARGET, + "other.play.minekube.net", + ) + } + + persistent.shutdown() + } + + private class FakeIngress( + private val failuresBeforeSuccess: Int = 0, + ) : DirectShareIngress { + val starts = AtomicInteger() + val closes = AtomicInteger() + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + val attempt = starts.incrementAndGet() + if (attempt <= failuresBeforeSuccess) { + error("simulated direct startup failure") + } + return DirectShareHandle( + invitation = INVITATION, + 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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt index f65b6c38b..af8e4a7f1 100644 --- 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 @@ -3,11 +3,16 @@ 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.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.tunnel.p2p.DirectP2pAuthMode @@ -75,6 +80,58 @@ class FriendsViewModelTest { ) } + @Test + fun `title friends state exposes only incoming friend 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.single() + assertEquals(friendRequestId, incoming.requestId) + assertEquals("bob", incoming.displayName) + assertEquals(Ingress.CONNECT, incoming.ingress) + 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)) @@ -155,16 +212,42 @@ class FriendsViewModelTest { ), ), ) + 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, + ), + ), + ) 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) @@ -179,6 +262,7 @@ class FriendsViewModelTest { online = true, description = "Robin's Remote World", notifyWhenOnline = true, + route = ShareRoute.CONNECT, ), ), ) From 9ed2b51aa8dec4b4f7c4a88c1348c9f14174fe3f Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 12:27:52 +0200 Subject: [PATCH 037/188] fix(share): pair friends over libp2p only --- .../connect/share/direct/ShareInviteCodec.kt | 75 +++++++-- .../friend/FriendControlChannelHandler.kt | 2 +- .../connect/share/friend/FriendControlWire.kt | 48 +----- .../connect/share/friend/FriendStore.kt | 30 +++- .../share/GatewayMinecraftBridgeTest.kt | 40 ++--- .../share/ShareConnectionGatewayTest.kt | 33 ++-- .../share/direct/ShareInviteCodecTest.kt | 36 +++++ .../friend/FriendControlChannelHandlerTest.kt | 32 ++-- .../share/friend/FriendControlWireTest.kt | 10 +- .../v1_21_11/ConnectShare12111Client.kt | 4 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 7 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 1 + .../fabric/v26_2/ConnectShare262Client.kt | 4 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 7 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 1 + .../share/fabric/FabricShareBootstrap.kt | 14 +- .../share/fabric/FabricShareBrowser.kt | 17 ++ .../connect/share/fabric/FriendCardIssuer.kt | 30 ++-- .../share/fabric/FriendPairingClient.kt | 5 + .../share/fabric/FriendRequestClient.kt | 59 +------ .../share/fabric/FriendRequestServer.kt | 31 +++- .../share/fabric/ui/FriendsViewModel.kt | 16 +- .../share/fabric/FriendCardIssuerTest.kt | 2 + .../fabric/FriendPairingDirectE2ETest.kt | 1 - .../share/fabric/FriendPairingE2ETest.kt | 149 +++++------------- .../share/fabric/FriendRequestClientTest.kt | 27 ++-- .../share/fabric/FriendRequestServerTest.kt | 51 +++++- .../share/fabric/ui/FriendsViewModelTest.kt | 39 ++++- 28 files changed, 416 insertions(+), 355 deletions(-) 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 index 539ae798d..f40f4825c 100644 --- 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 @@ -20,6 +20,7 @@ class ShareInvitePayload( val internetDirectEnabled: Boolean, val directCandidates: List, val capability: String, + val displayName: String? = null, ) { override fun equals(other: Any?): Boolean = other is ShareInvitePayload && @@ -30,7 +31,8 @@ class ShareInvitePayload( peerId == other.peerId && internetDirectEnabled == other.internetDirectEnabled && directCandidates == other.directCandidates && - capability == other.capability + capability == other.capability && + displayName == other.displayName override fun hashCode(): Int { var result = wireVersion @@ -41,6 +43,7 @@ class ShareInvitePayload( result = 31 * result + internetDirectEnabled.hashCode() result = 31 * result + directCandidates.hashCode() result = 31 * result + capability.hashCode() + result = 31 * result + (displayName?.hashCode() ?: 0) return result } @@ -49,7 +52,8 @@ class ShareInvitePayload( "expiresAtEpochMillis=$expiresAtEpochMillis, " + "connectAddress=$connectAddress, peerId=$peerId, " + "internetDirectEnabled=$internetDirectEnabled, " + - "directCandidates=, capability=)" + "directCandidates=, capability=, " + + "displayName=$displayName)" } class SignedShareInvite( @@ -107,16 +111,26 @@ sealed interface ShareInviteError { } object ShareInviteCodec { - const val WIRE_VERSION = 1 + 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 FIELD_COUNT = 10 - private const val UNSIGNED_FIELD_COUNT = 9 + 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 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(FIELD_COUNT) + writer.array(fieldCount(invite.payload.wireVersion)) writer.invitePayload(invite.payload) writer.bytes(invite.publicKey) writer.bytes(invite.signature) @@ -129,7 +143,13 @@ object ShareInviteCodec { payload: ShareInvitePayload, publicKey: ByteArray, ): ByteArray = CborWriter().apply { - array(UNSIGNED_FIELD_COUNT) + 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() @@ -149,7 +169,10 @@ object ShareInviteCodec { } return either { ensure(verify(parsed)) { ShareInviteError.InvalidSignature } - ensure(parsed.payload.wireVersion == WIRE_VERSION) { + ensure( + parsed.payload.wireVersion == LEGACY_WIRE_VERSION || + parsed.payload.wireVersion == WIRE_VERSION, + ) { ShareInviteError.UnsupportedVersion(parsed.payload.wireVersion) } ensure(parsed.payload.expiresAtEpochMillis >= now.toEpochMilli()) { @@ -165,6 +188,14 @@ object ShareInviteCodec { ) { ShareInviteError.PeerMismatch } + ensure( + parsed.payload.displayName?.let { displayName -> + displayName == displayName.trim() && + displayName.length in 1..MAX_DISPLAY_NAME_LENGTH + } != false, + ) { + ShareInviteError.Malformed + } parsed } } @@ -203,8 +234,25 @@ object ShareInviteCodec { 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() @@ -279,9 +327,11 @@ object ShareInviteCodec { private var offset = 0 fun readInvite(): SignedShareInvite { - require(readLength(4) == FIELD_COUNT) + val fields = readLength(4) + val wireVersion = unsigned().toInt() + require(fields == fieldCount(wireVersion)) val payload = ShareInvitePayload( - wireVersion = unsigned().toInt(), + wireVersion = wireVersion, shareId = UUID.fromString(text()), expiresAtEpochMillis = unsigned(), connectAddress = nullableText(), @@ -289,6 +339,11 @@ object ShareInviteCodec { internetDirectEnabled = bool(), directCandidates = List(readLength(4)) { text() }, capability = text(), + displayName = if (wireVersion == LEGACY_WIRE_VERSION) { + null + } else { + nullableText() + }, ) val publicKey = byteString() val signature = byteString() 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 index bc4319b45..8c5b3991d 100644 --- 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 @@ -58,7 +58,7 @@ class FriendControlChannelHandler( if (!controlHandshake) { when ( val inspected = - FriendControlWire.inspectControlHandshake(accumulated) + FriendControlWire.inspectControlRequest(accumulated) ) { FriendControlDecode.Incomplete -> return FriendControlDecode.Invalid -> { 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 index 0208caa61..bc8f510a0 100644 --- 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 @@ -38,11 +38,9 @@ sealed interface FriendControlDecode { object FriendControlWire { const val MAX_REQUEST_BYTES = 65_536 - const val CONTROL_HANDSHAKE_PORT = 24_454 private const val STATUS_INTENTION = 1 private const val HANDSHAKE_PACKET_ID = 0 - private const val STATUS_REQUEST_PACKET_ID = 0 private const val CONTROL_REQUEST_PACKET_ID = 0x43F1 private const val CONTROL_RESPONSE_PACKET_ID = 0x43F2 private const val MAX_ADDRESS_BYTES = 255 @@ -50,16 +48,8 @@ object FriendControlWire { private const val MAX_INVITATION_BYTES = 32_768 fun encodeRequest( - protocolVersion: Int, - serverAddress: String, request: FriendControlRequest, ): ByteArray { - require(protocolVersion >= 0) { - "Minecraft protocol version must not be negative" - } - require(serverAddress.toByteArray(StandardCharsets.UTF_8).size <= MAX_ADDRESS_BYTES) { - "Minecraft server address is too long" - } require( request.displayName.trim().isNotEmpty() && request.displayName.toByteArray(StandardCharsets.UTF_8).size <= @@ -75,17 +65,6 @@ object FriendControlWire { } val output = ByteArrayOutputStream() - output.writePacket { - writeVarInt(HANDSHAKE_PACKET_ID) - writeVarInt(protocolVersion) - writeString(serverAddress) - write((CONTROL_HANDSHAKE_PORT ushr 8) and 0xff) - write(CONTROL_HANDSHAKE_PORT and 0xff) - writeVarInt(STATUS_INTENTION) - } - output.writePacket { - writeVarInt(STATUS_REQUEST_PACKET_ID) - } output.writePacket { writeVarInt(CONTROL_REQUEST_PACKET_ID) writeLong(request.requestId.mostSignificantBits) @@ -107,20 +86,6 @@ object FriendControlWire { return FriendControlDecode.Invalid } return decode(bytes) { - val handshake = readPacket() - ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) - handshake.readVarInt() - handshake.readString(MAX_ADDRESS_BYTES) - ensure(handshake.readUnsignedShort() == CONTROL_HANDSHAKE_PORT) - ensure(handshake.readVarInt() == STATUS_INTENTION) - handshake.ensureFinished() - - val statusRequest = readPacket() - ensure( - statusRequest.readVarInt() == STATUS_REQUEST_PACKET_ID, - ) - statusRequest.ensureFinished() - val control = readPacket() ensure(control.readVarInt() == CONTROL_REQUEST_PACKET_ID) val requestId = UUID( @@ -156,18 +121,11 @@ object FriendControlWire { false } - fun inspectControlHandshake( + fun inspectControlRequest( bytes: ByteArray, ): FriendControlDecode = decode(bytes) { - val handshake = readPacket() - ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) - handshake.readVarInt() - handshake.readString(MAX_ADDRESS_BYTES) - val port = handshake.readUnsignedShort() - val intention = handshake.readVarInt() - handshake.ensureFinished() - port == CONTROL_HANDSHAKE_PORT && - intention == STATUS_INTENTION + val firstPacket = readPacket() + firstPacket.readVarInt() == CONTROL_REQUEST_PACKET_ID } fun encodeResponse(response: FriendControlResponse): ByteArray { 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 index 382655770..fe3d14b6e 100644 --- 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 @@ -1,8 +1,10 @@ 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 @@ -98,6 +100,10 @@ class FriendStore( FriendRelationshipStatus.PENDING_OUTGOING } + @Synchronized + fun relationship(peerId: String): Option = + read().firstOrNull { it.peerId == peerId }.toOption() + @Synchronized fun accept( invitationUri: String, @@ -111,6 +117,20 @@ class FriendStore( now = now, ) + @Synchronized + fun acceptAndAllowJoin( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + allowAutomaticJoin = true, + now = now, + ) + @Synchronized fun sendRequest( invitationUri: String, @@ -138,6 +158,7 @@ class FriendStore( invitationUri: String, displayName: String, relationshipStatus: FriendRelationshipStatus, + allowAutomaticJoin: Boolean = false, now: Instant, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) @@ -171,7 +192,14 @@ class FriendStore( connectAddress = invite.payload.connectAddress, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, - permissions = existing?.permissions ?: FriendPermissions(), + permissions = (existing?.permissions ?: FriendPermissions()) + .let { permissions -> + if (allowAutomaticJoin) { + permissions.copy(canJoinAutomatically = true) + } else { + permissions + } + }, relationshipStatus = effectiveRelationshipStatus, ) write( 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 index 71593ff55..2ece42efb 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt @@ -149,34 +149,16 @@ class GatewayMinecraftBridgeTest { } private companion object { - val CONTROL_REQUEST = com.minekube.connect.share.friend - .FriendControlRequest( - requestId = java.util.UUID.fromString( - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - ), - displayName = "ordinary", - invitation = "minekube://share/ordinary", - ) - val MINECRAFT_BYTES = - com.minekube.connect.share.friend.FriendControlWire - .encodeRequest( - protocolVersion = 1_075, - serverAddress = "ordinary-minecraft", - request = CONTROL_REQUEST, - ).copyOf().also { bytes -> - val port = - com.minekube.connect.share.friend - .FriendControlWire - .CONTROL_HANDSHAKE_PORT - val high = port ushr 8 - val low = port and 0xff - val index = bytes.indices.first { - it + 1 < bytes.size && - bytes[it].toInt() and 0xff == high && - bytes[it + 1].toInt() and 0xff == low - } - bytes[index] = (25_565 ushr 8).toByte() - bytes[index + 1] = 25_565.toByte() - } + 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 index 11e144686..20cdab1c9 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -41,8 +41,6 @@ class ShareConnectionGatewayTest { socket.getOutputStream().apply { write( FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "connect-share", request = REQUEST, ), ) @@ -199,8 +197,6 @@ class ShareConnectionGatewayTest { channel.writeAndFlush( Unpooled.wrappedBuffer( FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "friend-control", request = REQUEST, ), ), @@ -293,23 +289,16 @@ class ShareConnectionGatewayTest { invitation = "minekube://share/sender-card", ) const val HOST_CARD = "minekube://share/host-card" - val ORDINARY_MINECRAFT_BYTES = - FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "ordinary-minecraft", - request = REQUEST, - ).copyOf().also { bytes -> - val controlHigh = - FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 - val controlLow = - FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff - val portIndex = bytes.indices.first { - it + 1 < bytes.size && - bytes[it].toInt() and 0xff == controlHigh && - bytes[it + 1].toInt() and 0xff == controlLow - } - bytes[portIndex] = (25_565 ushr 8).toByte() - bytes[portIndex + 1] = 25_565.toByte() - } + 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/direct/ShareInviteCodecTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt index 64829b1a9..66b97f635 100644 --- 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 @@ -47,6 +47,40 @@ class ShareInviteCodecTest { 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() @@ -105,6 +139,7 @@ class ShareInviteCodecTest { 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", ), @@ -117,6 +152,7 @@ class ShareInviteCodecTest { internetDirectEnabled = true, directCandidates = directCandidates, capability = CAPABILITY, + displayName = displayName, ) private fun ShareInvitePayload.signWith(keyPair: KeyPair): SignedShareInvite { 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 index 551f0179d..94c54f17c 100644 --- 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 @@ -19,22 +19,7 @@ import kotlin.test.assertTrue class FriendControlChannelHandlerTest { @Test fun `ordinary Minecraft traffic passes through unchanged`() { - val ordinary = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "localhost", - request = REQUEST, - ).copyOf() - val controlHigh = - FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 - val controlLow = - FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff - val portIndex = ordinary.indices.first { - it + 1 < ordinary.size && - ordinary[it].toInt() and 0xff == controlHigh && - ordinary[it + 1].toInt() and 0xff == controlLow - } - ordinary[portIndex] = (25_565 ushr 8).toByte() - ordinary[portIndex + 1] = 25_565.toByte() + val ordinary = ORDINARY_MINECRAFT_HANDSHAKE val channel = EmbeddedChannel( FriendControlChannelHandler { _, _ -> error("Ordinary traffic must not reach friend control") @@ -67,8 +52,6 @@ class FriendControlChannelHandlerTest { ) channel.attr(DirectSessionAttributes.SESSION).set(DIRECT_SESSION) val encoded = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "connect-share", request = REQUEST, ) @@ -118,8 +101,6 @@ class FriendControlChannelHandlerTest { channel.writeInbound( Unpooled.wrappedBuffer( FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "purple-del.play.minekube.net", request = REQUEST, ), ), @@ -156,5 +137,16 @@ class FriendControlChannelHandlerTest { 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 index 64b7dae81..41d70a8e0 100644 --- 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 @@ -3,12 +3,12 @@ package com.minekube.connect.share.friend 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 uses a status handshake and round trips without a login`() { + fun `request is a raw control frame instead of a Minecraft status ping`() { val request = FriendControlRequest( requestId = REQUEST_ID, displayName = "bob", @@ -16,8 +16,6 @@ class FriendControlWireTest { ) val encoded = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "purple-del.play.minekube.net", request = request, ) val decoded = assertIs>( @@ -26,7 +24,7 @@ class FriendControlWireTest { assertEquals(request, decoded.value) assertEquals(encoded.size, decoded.consumedBytes) - assertTrue(FriendControlWire.isStatusHandshake(encoded)) + assertFalse(FriendControlWire.isStatusHandshake(encoded)) } @Test @@ -54,8 +52,6 @@ class FriendControlWireTest { @Test fun `partial and oversized control frames are never accepted`() { val encoded = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "purple-del.play.minekube.net", request = FriendControlRequest( requestId = REQUEST_ID, displayName = "bob", 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 index ca606795b..380eda167 100644 --- 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 @@ -60,8 +60,6 @@ class ConnectShare12111Client : ClientModInitializer { ) val minecraftVersion = SharedConstants.getCurrentVersion().name() - val minecraftProtocolVersion = - SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) @@ -93,11 +91,11 @@ class ConnectShare12111Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = minecraftVersion, - minecraftProtocolVersion = minecraftProtocolVersion, worldAvailable = worldAvailableSnapshot.get(), friendStore = friendStore, playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, bridgeFactory = { admission, admissionScope, 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 index ed76f443d..ff8b50e91 100644 --- 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 @@ -341,6 +341,11 @@ class ShareJoinScreen( setValue(invitationValue) setResponder { invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } refresh() } }, @@ -675,8 +680,6 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), - ownConnectAddress = - ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { 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 index 84535e097..fa1eb77d7 100644 --- 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 @@ -89,6 +89,7 @@ class Fabric12111ArtifactTest { "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) 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 index d4a6fada3..0340b73f2 100644 --- 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 @@ -60,8 +60,6 @@ class ConnectShare262Client : ClientModInitializer { ) val minecraftVersion = SharedConstants.getCurrentVersion().name() - val minecraftProtocolVersion = - SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) @@ -93,11 +91,11 @@ class ConnectShare262Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = minecraftVersion, - minecraftProtocolVersion = minecraftProtocolVersion, worldAvailable = worldAvailableSnapshot.get(), friendStore = friendStore, playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, bridgeFactory = { admission, admissionScope, 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 index 33c5d796a..712194978 100644 --- 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 @@ -341,6 +341,11 @@ class ShareJoinScreen( setValue(invitationValue) setResponder { invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } refresh() } }, @@ -675,8 +680,6 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), - ownConnectAddress = - ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { 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 index 6fe5b64ac..9660c135a 100644 --- 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 @@ -91,6 +91,7 @@ class Fabric262ArtifactTest { "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) 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 index f41a07608..7f19c7bcf 100644 --- 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 @@ -32,11 +32,11 @@ object FabricShareBootstrap { scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, - minecraftProtocolVersion: Int, worldAvailable: Boolean, friendStore: FriendStore, playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, + playerDisplayName: () -> String? = { null }, bridgeFactory: ( AdmissionController, @@ -97,9 +97,11 @@ object FabricShareBootstrap { val endpointIdentity = identityStore.currentOrCreate() val ownConnectAddress = "${endpointIdentity.endpoint}.play.minekube.net" - val friendCardIssuer = FriendCardIssuer(dataDirectory) { - ownConnectAddress - } + val friendCardIssuer = FriendCardIssuer( + dataDirectory = dataDirectory, + displayName = playerDisplayName, + connectAddress = { ownConnectAddress }, + ) val friendCardReceiver = FriendCardReceiver(friendStore) val friendsViewModel = FriendsViewModel(friendStore) val friendRequestServer = FriendRequestServer( @@ -181,9 +183,7 @@ object FabricShareBootstrap { resumeShare = viewModel::resumeIfEnabled, worldAvailabilityChanged = viewModel::setWorldAvailable, ) - val friendRequestClient = FriendRequestClient( - minecraftProtocolVersion, - ) + val friendRequestClient = FriendRequestClient() val friendPairingClient = FriendPairingClient( store = friendStore, issuer = friendCardIssuer, 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 index 3ec45f453..3cfe68594 100644 --- 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 @@ -257,6 +257,23 @@ class FabricShareBrowser private constructor( GuestJoinFailure.NoRoute.left() } + suspend fun openFriendControl( + friend: SavedFriend, + authMode: DirectP2pAuthMode, + ): Either = + withContext(ioDispatcher) { + val discovered = matchingLanShare(friend) + ?: return@withContext GuestJoinFailure.NoRoute.left() + openDirect( + route = ShareRoute.DIRECT_LAN, + address = discovered.lanAddress, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = LAN_TIMEOUT, + )?.right() ?: GuestJoinFailure.NoRoute.left() + } + suspend fun probeLan( friend: SavedFriend, authMode: DirectP2pAuthMode, 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 index 1316d647e..220414f43 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -30,14 +32,11 @@ class FriendCardReceiver( authenticatedMinecraftUuid: UUID?, now: Instant = Instant.now(), ): Either = - store.accept(invitation, displayName, now).flatMap { friend -> - store.updatePermissions( - friend.peerId, - friend.permissions.copy( - canJoinAutomatically = true, - ), - ) - }.flatMap { friend -> + store.acceptAndAllowJoin( + invitation, + displayName, + now, + ).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> store.linkMinecraftIdentity( friend.peerId, @@ -49,11 +48,19 @@ class FriendCardReceiver( class FriendCardIssuer( private val dataDirectory: Path, + private val displayName: () -> String? = { null }, private val connectAddress: suspend () -> String?, ) { suspend fun issue( now: Instant = Instant.now(), - ): Either = + ): Either = either { + val normalizedDisplayName = displayName()?.trim() + ensure( + normalizedDisplayName == null || + normalizedDisplayName.length in 1..MAX_DISPLAY_NAME_LENGTH, + ) { + FriendCardIssueFailure + } Either.catch { val access = ShareAccessIdentityStore( dataDirectory, @@ -72,6 +79,7 @@ class FriendCardIssuer( internetDirectEnabled = false, directCandidates = emptyList(), capability = access.capability, + displayName = normalizedDisplayName, ) val publicKey = node.publicKey() val unsigned = ShareInviteCodec.unsignedBytes( @@ -88,11 +96,13 @@ class FriendCardIssuer( } }.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/FriendPairingClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt index 236f1fca1..9a54ef96e 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -68,6 +69,10 @@ class FriendPairingClient( 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( 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 index d0135036d..edc1c68a4 100644 --- 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 @@ -9,7 +9,6 @@ import com.minekube.connect.share.friend.FriendControlResponse import com.minekube.connect.share.friend.FriendControlWire import java.io.ByteArrayOutputStream import java.io.InputStream -import java.net.InetSocketAddress import java.net.Socket import java.net.SocketTimeoutException import java.time.Duration @@ -46,32 +45,28 @@ sealed interface FriendRequestFailure { } class FriendRequestClient( - private val protocolVersion: Int, 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, + target: GuestJoinTarget.Direct, request: FriendControlRequest, onReceived: () -> Unit, ): Either = withContext(ioDispatcher) { target.use { - val route = target.routeTarget() val socket = Socket() val cancellation = coroutineContext[Job] ?.invokeOnCompletion { socket.close() } try { socket.connect( - route.socketAddress, + target.localAddress, connectTimeout.toMillis().toInt(), ) socket.soTimeout = READ_POLL_MILLIS socket.getOutputStream().apply { write( FriendControlWire.encodeRequest( - protocolVersion = protocolVersion, - serverAddress = route.handshakeAddress, request = request, ), ) @@ -176,57 +171,7 @@ class FriendRequestClient( } } - private fun GuestJoinTarget.routeTarget(): RouteTarget = when (this) { - is GuestJoinTarget.Connect -> { - val parsed = parseAddress(publicAddress) - RouteTarget( - socketAddress = parsed, - handshakeAddress = parsed.hostString, - ) - } - - is GuestJoinTarget.Direct -> RouteTarget( - socketAddress = localAddress, - handshakeAddress = "connect-share", - ) - } - - private fun parseAddress(value: String): InetSocketAddress { - val trimmed = value.trim() - if (trimmed.startsWith("[")) { - val closing = trimmed.indexOf(']') - require(closing > 1) { "Friend address is invalid" } - val host = trimmed.substring(1, closing) - val port = trimmed.substring(closing + 1) - .removePrefix(":") - .takeIf(String::isNotEmpty) - ?.toInt() - ?: DEFAULT_MINECRAFT_PORT - return InetSocketAddress(host, port) - } - val colon = trimmed.lastIndexOf(':') - val hasSingleColon = - colon > 0 && trimmed.indexOf(':') == colon - val host = if (hasSingleColon) { - trimmed.substring(0, colon) - } else { - trimmed - } - val port = if (hasSingleColon) { - trimmed.substring(colon + 1).toInt() - } else { - DEFAULT_MINECRAFT_PORT - } - return InetSocketAddress(host, port) - } - - private data class RouteTarget( - val socketAddress: InetSocketAddress, - val handshakeAddress: String, - ) - private companion object { - const val DEFAULT_MINECRAFT_PORT = 25_565 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 index f46c8cc93..d5c8f465c 100644 --- 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 @@ -4,11 +4,13 @@ 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.FriendControlServer +import com.minekube.connect.share.friend.FriendRelationshipStatus import com.minekube.connect.share.friend.FriendStore import java.time.Instant import java.util.Base64 @@ -54,27 +56,42 @@ class FriendRequestServer( 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 ( - context.directPeerId != null && - context.directPeerId != senderPeerId - ) { + if (authenticatedPeerId != senderPeerId) { return FriendControlResponse.Invalid } val senderKey = Base64.getEncoder() .encodeToString(invitation.publicKey) - val existing = friendStore.all().firstOrNull { - it.peerId == senderPeerId - } + val existing = friendStore.relationship(senderPeerId).getOrNull() if (existing != null) { if (existing.publicKeyBase64 != senderKey) { return FriendControlResponse.Invalid } + if ( + existing.relationshipStatus == + FriendRelationshipStatus.PENDING_OUTGOING + ) { + val accepted = receiver.receive( + invitation = request.invitation, + displayName = request.displayName, + authenticatedMinecraftUuid = null, + now = instant, + ) + if (accepted.isLeft()) { + return FriendControlResponse.Invalid + } + notifyRelationshipChanged() + } return issueHostCard(instant) } 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 index 059b07ad9..34afb59a6 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -12,6 +14,7 @@ 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.direct.ShareInviteCodec import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.SavedFriend @@ -77,6 +80,14 @@ class FriendsViewModel( }, ) + 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 -> @@ -175,11 +186,10 @@ class FriendsViewModel( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, - ownConnectAddress: String? = null, - ): Either { + ): Either { val request = outgoingRequest(peerId) ?: return GuestJoinFailure.NoRoute.left() - return browser.join(request, authMode, ownConnectAddress) + return browser.openFriendControl(request, authMode) } fun reload() { 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 index a7e777ee3..668f7c1ff 100644 --- 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 @@ -23,6 +23,7 @@ class FriendCardIssuerTest { val issuer = FriendCardIssuer( dataDirectory = tempDir, connectAddress = { "purple-del.play.minekube.net" }, + displayName = { "RoboFlax2" }, ) val first = assertIs>( @@ -54,6 +55,7 @@ class FriendCardIssuerTest { "purple-del.play.minekube.net", firstInvite.payload.connectAddress, ) + assertEquals("RoboFlax2", firstInvite.payload.displayName) assertTrue(firstInvite.payload.directCandidates.isEmpty()) } 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 index 67a74cbad..40f455d6f 100644 --- 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 @@ -109,7 +109,6 @@ class FriendPairingDirectE2ETest { }, receiver = FriendCardReceiver(senderStore), requestClient = FriendRequestClient( - protocolVersion = 1_075, ioDispatcher = Dispatchers.IO, connectTimeout = Duration.ofSeconds(3), decisionTimeout = Duration.ofSeconds(5), 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 index 095d835dd..5dc521b4e 100644 --- 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 @@ -1,24 +1,18 @@ package com.minekube.connect.share.fabric +import arrow.core.Either import arrow.core.right -import com.minekube.connect.share.ShareConnectionGateway -import com.minekube.connect.share.admission.AdmissionController import com.minekube.connect.share.friend.FriendRelationshipStatus import com.minekube.connect.share.friend.FriendStore 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.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds 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 FriendPairingE2ETest { @@ -26,113 +20,52 @@ class FriendPairingE2ETest { lateinit var tempDir: Path @Test - fun `signed request accepted through title gateway persists mutual friendship`() = + fun `Connect Minecraft route is refused before friend delivery`() = runBlocking { val now = Instant.parse("2026-07-31T10:30:00Z") - val hostDirectory = tempDir.resolve("host") - val senderDirectory = tempDir.resolve("sender") - val hostStore = FriendStore(hostDirectory) - val senderStore = FriendStore(senderDirectory) - val hostAddress = AtomicReference() - var hostRelationshipsChanged = 0 - val hostIssuer = FriendCardIssuer(hostDirectory) { - hostAddress.get() - } - val admission = AdmissionController( - scope = this, - timeout = 10.seconds, - maxPending = 8, - connectedCount = { 0 }, - maxGuests = { 8 }, + val hostIssuer = FriendCardIssuer( + dataDirectory = tempDir.resolve("host"), + connectAddress = { "host.play.minekube.net" }, ) - val hostServer = FriendRequestServer( - scope = this, - admission = admission, - issuer = hostIssuer, - receiver = FriendCardReceiver(hostStore), - friendStore = hostStore, + 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, - onRelationshipChanged = { - hostRelationshipsChanged++ - }, ) - ShareConnectionGateway.bind(hostServer).use { gateway -> - hostAddress.set( - "${gateway.directAddress.hostString}:" + - gateway.directAddress.port, - ) - val invitation = hostIssuer.issue(now).getOrNull()!! - val pairing = FriendPairingClient( - store = senderStore, - issuer = FriendCardIssuer(senderDirectory) { - "sender.play.minekube.net" - }, - receiver = FriendCardReceiver(senderStore), - requestClient = FriendRequestClient( - protocolVersion = 1_075, - ioDispatcher = Dispatchers.IO, - connectTimeout = Duration.ofSeconds(2), - decisionTimeout = Duration.ofSeconds(5), - ), - now = { now }, - ioDispatcher = Dispatchers.IO, - ) - var received = false - - val result = async { - pairing.send( - invitation = invitation, - friendDisplayName = "RoboFlax2", - senderDisplayName = "bob", - route = { saved -> - GuestJoinTarget.Connect( - checkNotNull(saved.connectAddress), - ).right() - }, - onReceived = { received = true }, - ) - } + var received = false - val pending = withTimeout(2.seconds) { - admission.pending.first { it.isNotEmpty() }.single() - } - assertTrue(received) - assertTrue(hostStore.all().isEmpty()) - assertEquals( - FriendRelationshipStatus.PENDING_OUTGOING, - senderStore.outgoingRequests() - .single() - .relationshipStatus, - ) - - admission.answer(pending.requestId, allow = true) - val accepted = result.await().getOrNull()!! + val result = pairing.send( + invitation = invitation, + friendDisplayName = "RoboFlax2", + senderDisplayName = "bob", + route = { + GuestJoinTarget.Connect( + "host.play.minekube.net", + ).right() + }, + onReceived = { received = true }, + ) - assertEquals( - FriendRelationshipStatus.CONFIRMED, - accepted.relationshipStatus, - ) - assertTrue(senderStore.outgoingRequests().isEmpty()) - assertEquals("RoboFlax2", senderStore.all().single().displayName) - assertEquals("bob", hostStore.all().single().displayName) - assertEquals(1, hostRelationshipsChanged) - assertTrue( - senderStore.all() - .single() - .permissions - .canJoinAutomatically, - ) - assertTrue( - hostStore.all() - .single() - .permissions - .canJoinAutomatically, - ) - assertFalse( - senderStore.all().single().peerId == - hostStore.all().single().peerId, - ) - } + 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/FriendRequestClientTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt index 8002beda7..afcd5df0a 100644 --- 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 @@ -1,12 +1,15 @@ 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.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 @@ -25,7 +28,7 @@ import kotlinx.coroutines.runBlocking class FriendRequestClientTest { @Test - fun `Connect control request waits for remote acceptance without joining`() = + fun `libp2p control request waits for remote acceptance without joining`() = runBlocking { val server = ServerSocket( 0, @@ -61,14 +64,11 @@ class FriendRequestClientTest { } var acknowledged = false val client = FriendRequestClient( - protocolVersion = 1_075, ioDispatcher = Dispatchers.IO, ) val result = client.exchange( - target = GuestJoinTarget.Connect( - "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", - ), + target = directTarget(server), request = REQUEST, onReceived = { acknowledged = true }, ) @@ -109,15 +109,12 @@ class FriendRequestClientTest { } } val client = FriendRequestClient( - protocolVersion = 1_075, ioDispatcher = Dispatchers.IO, decisionTimeout = Duration.ofSeconds(30), ) val pending = launch { client.exchange( - target = GuestJoinTarget.Connect( - "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", - ), + target = directTarget(server), request = REQUEST, onReceived = {}, ) @@ -149,6 +146,18 @@ class FriendRequestClientTest { 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( 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 index 0cf4be038..7287cb41d 100644 --- 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 @@ -19,6 +19,7 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.test.StandardTestDispatcher 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) @@ -72,7 +73,8 @@ class FriendRequestServerTest { } @Test - fun `decline and direct identity mismatch never create trust`() = runTest { + 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")) @@ -102,16 +104,51 @@ class FriendRequestServerTest { request(senderCard), ).toCompletableFuture() runCurrent() - admission.answer( - admission.pending.value.single().requestId, - allow = false, - ) - runCurrent() - assertEquals(FriendControlResponse.Declined, connect.getNow(null)) + assertEquals(FriendControlResponse.Invalid, connect.getNow(null)) + 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) + assertTrue(confirmed.permissions.canJoinAutomatically) + assertTrue(hostStore.outgoingRequests().isEmpty()) + assertEquals(1, relationshipsChanged) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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 index af8e4a7f1..8f060b043 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -56,6 +57,18 @@ class FriendsViewModelTest { assertEquals(null, viewModel.state.value.safeMessage) } + @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)) @@ -349,7 +362,30 @@ class FriendsViewModelTest { browser.close() } - private fun signedLink(): String { + @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, @@ -362,6 +398,7 @@ class FriendsViewModelTest { internetDirectEnabled = false, directCandidates = emptyList(), capability = CAPABILITY, + displayName = displayName, ) val unsigned = ShareInviteCodec.unsignedBytes( payload, From 3621a06ebb27cfead2eee8d510b192ed494a662e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 13:03:41 +0200 Subject: [PATCH 038/188] fix(share): make libp2p friend discovery reliable --- .../tunnel/p2p/DirectP2pNodeRuntime.java | 156 +++++++++++++++--- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 41 +++++ .../share/fabric/FabricDirectPeerRuntime.kt | 103 ++++++++++++ .../share/fabric/FabricDirectShareIngress.kt | 14 ++ .../share/fabric/FabricShareBootstrap.kt | 11 +- .../share/fabric/FabricShareBrowser.kt | 7 + .../fabric/FabricDirectPeerRuntimeTest.kt | 109 ++++++++++++ 7 files changed, 417 insertions(+), 24 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt 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 index c17406317..cbf0356f3 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -36,7 +36,9 @@ import io.libp2p.core.multiformats.MultiaddrComponent; import io.libp2p.core.multiformats.Protocol; import io.libp2p.core.multistream.StrictProtocolBinding; -import io.libp2p.discovery.MDnsDiscovery; +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; @@ -52,11 +54,13 @@ 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; @@ -73,7 +77,6 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import kotlin.Pair; -import kotlin.Unit; /** * Child-loaded implementation. No method signature may expose libp2p, Netty, @@ -98,11 +101,13 @@ final class DirectP2pNodeRuntime { private final List proxies = new CopyOnWriteArrayList<>(); private final java.util.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 MDnsDiscovery discovery; + private JmDNS discovery; private DirectP2pDiscoveryListener discoveryListener; private boolean started; private boolean closed; @@ -137,11 +142,19 @@ synchronized DirectP2pHostInfo startHost( } hostConfig = Objects.requireNonNull(config, "config"); hostHandler = Objects.requireNonNull(handler, "handler"); - host = Libp2pTunnelTransportRuntime.createHost( - privateKey, - "/ip4/0.0.0.0/tcp/0"); - installProtocols(host); - startHostIfNeeded(); + 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(); @@ -270,7 +283,7 @@ synchronized void close() { } closed = true; if (discovery != null) { - await(discovery.stop(), START_TIMEOUT_SECONDS, "stop Connect Share LAN discovery"); + discovery.stop(); discovery = null; } for (ProxyRuntime proxy : proxies) { @@ -311,16 +324,121 @@ private synchronized void startMdns() { if (discovery != null) { return; } - discovery = new MDnsDiscovery( - host, - MDNS_SERVICE, - MDNS_QUERY_INTERVAL_SECONDS, - MdnsAddressSelector.systemAddress()); - discovery.addHandler(peer -> { - onMdnsPeer(peer); - return Unit.INSTANCE; - }); - await(discovery.start(), START_TIMEOUT_SECONDS, "start Connect Share LAN discovery"); + InetAddress address = MdnsAddressSelector.systemAddress(); + JmDNS started = JmDNS.create(address); + 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(); + String peerId = host.getPeerId().toBase58(); + 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); + } + } + + 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) { 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 index 9a73a21d4..a1722a124 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -232,6 +232,47 @@ void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { 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 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 directNodeNeverAdvertisesOrAcceptsCircuitRelayAddresses() { host = new DirectP2pNode(); 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..5b547f486 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt @@ -0,0 +1,103 @@ +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 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, + displayName: () -> String, + ) : this( + node = CoreFabricDirectPeerNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ), + dataDirectory = dataDirectory, + displayName = displayName, + ) + + private constructor( + node: FabricDirectPeerNode, + dataDirectory: Path, + displayName: () -> String, + ) : this( + browser = FabricShareBrowser(node), + ingress = FabricDirectShareIngress( + node = node, + dataDirectory = dataDirectory, + 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) { + node.publish(invitation) + } + + 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 index 974207b5b..591f47a41 100644 --- 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 @@ -47,6 +47,20 @@ class FabricDirectShareIngress private constructor( localSocket = ::openTaggedLoopbackSocket, ) + internal constructor( + node: FabricDirectNode, + dataDirectory: Path, + displayName: () -> String, + ) : this( + nodeFactory = { node }, + now = Instant::now, + accessIdentity = ShareAccessIdentityStore( + dataDirectory, + )::currentOrCreate, + displayName = displayName, + localSocket = ::openTaggedLoopbackSocket, + ) + override suspend fun start( options: ShareOptions, target: SocketAddress, 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 index 7f19c7bcf..0d6e6a75f 100644 --- 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 @@ -114,7 +114,11 @@ object FabricShareBootstrap { val gateway = ShareConnectionGateway.bind(friendRequestServer) var browser: FabricShareBrowser? = null try { - val activeBrowser = FabricShareBrowser(dataDirectory) + val directPeer = FabricDirectPeerRuntime( + dataDirectory = dataDirectory, + displayName = worldDisplayName, + ) + val activeBrowser = directPeer.browser browser = activeBrowser activeBrowser.start().leftOrNull()?.let { logger.warn(it.safeMessage) @@ -141,10 +145,7 @@ object FabricShareBootstrap { ), ) val directIngress = PersistentDirectIngress( - FabricDirectShareIngress( - dataDirectory = dataDirectory, - displayName = worldDisplayName, - ), + directPeer.ingress, ) val coordinator = ShareCoordinator( bridge = bridge, 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 index 3cfe68594..f118e17b5 100644 --- 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 @@ -116,6 +116,13 @@ class FabricShareBrowser private constructor( 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() 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..3b5da75e8 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt @@ -0,0 +1,109 @@ +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() + } + + private class RecordingPeerNode : FabricDirectPeerNode { + private val keyPair = + KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + var discoveryStarts = 0 + var hostStarts = 0 + var publishes = 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) { + publishes++ + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy = error("not used") + + override fun close() = Unit + } + + private companion object { + const val PEER_ID = + "12D3KooWEHeJnnq1Rfwt679bTyTxkEdtyTC8peAJWsWCxtAJ4s9y" + } +} From 6082723aec55701df9ec18d841648b728c0306f6 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 14:02:42 +0200 Subject: [PATCH 039/188] feat(share): sync friend activity and removals --- .../share/admission/AdmissionController.kt | 17 ++ .../friend/FriendControlChannelHandler.kt | 161 +++++++++++++++- .../connect/share/friend/FriendControlWire.kt | 149 ++++++++++++++ .../connect/share/friend/FriendStore.kt | 182 +++++++++++++----- .../admission/AdmissionControllerTest.kt | 25 +++ .../friend/FriendControlChannelHandlerTest.kt | 38 ++++ .../share/friend/FriendControlWireTest.kt | 50 +++++ .../connect/share/friend/FriendStoreTest.kt | 44 ++++- .../v1_21_11/ConnectShare12111Client.kt | 85 ++++++-- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 135 +++++++++++-- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../fabric/v26_2/ConnectShare262Client.kt | 85 ++++++-- .../share/fabric/v26_2/ShareJoinScreen.kt | 135 +++++++++++-- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../share/fabric/ConnectShareClient.kt | 13 +- .../share/fabric/FabricShareBootstrap.kt | 76 +++++++- .../share/fabric/FriendActivityMonitor.kt | 58 ++++++ .../connect/share/fabric/FriendRemovalSync.kt | 43 +++++ .../share/fabric/FriendRequestClient.kt | 135 +++++++++++++ .../share/fabric/FriendRequestServer.kt | 114 +++++++++++ .../share/fabric/SocialEventTracker.kt | 66 +++++++ .../share/fabric/ui/FriendsViewModel.kt | 37 +++- .../share/fabric/FriendActivityMonitorTest.kt | 56 ++++++ .../fabric/FriendPairingDirectE2ETest.kt | 86 ++++++++- .../share/fabric/FriendRemovalSyncTest.kt | 72 +++++++ .../share/fabric/FriendRequestClientTest.kt | 90 +++++++++ .../share/fabric/FriendRequestServerTest.kt | 129 +++++++++++++ .../share/fabric/SocialEventTrackerTest.kt | 55 ++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 38 +++- 31 files changed, 2089 insertions(+), 137 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitor.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRemovalSync.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitorTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRemovalSyncTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt 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 index f1df32251..c587c61b4 100644 --- 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 @@ -116,6 +116,23 @@ class AdmissionController( complete(completed, answer) } + fun denyDirectPeer( + peerId: String, + purpose: AdmissionPurpose, + ): Int { + val denied = synchronized(lock) { + 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 resetShare() { val stopped = synchronized(lock) { val current = requests.values.toList() 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 index 8c5b3991d..50af91947 100644 --- 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 @@ -23,6 +23,30 @@ fun interface FriendControlServer { 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, + ) } class FriendControlChannelHandler( @@ -58,7 +82,7 @@ class FriendControlChannelHandler( if (!controlHandshake) { when ( val inspected = - FriendControlWire.inspectControlRequest(accumulated) + FriendControlWire.inspectControlMessage(accumulated) ) { FriendControlDecode.Incomplete -> return FriendControlDecode.Invalid -> { @@ -67,7 +91,7 @@ class FriendControlChannelHandler( } is FriendControlDecode.Decoded -> { - if (!inspected.value) { + if (inspected.value == FriendControlMessageKind.OTHER) { passThrough(context, accumulated) return } @@ -76,19 +100,88 @@ class FriendControlChannelHandler( } } - when (val decoded = FriendControlWire.decodeRequest(accumulated)) { + 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() - is FriendControlDecode.Decoded -> { - if (decoded.consumedBytes != accumulated.size) { - context.close() - return - } + } + } + + 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() @@ -110,7 +203,57 @@ class FriendControlChannelHandler( return } writeResponse(context, FriendControlResponse.Received) - val pending = server.handle(context.controlContext(), request) + 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() 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 index bc8f510a0..e253ff792 100644 --- 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 @@ -11,6 +11,33 @@ data class FriendControlRequest( val invitation: String, ) +data class FriendRemovalRequest( + val operationId: UUID, +) + +data class FriendActivityRequest(val requestId: UUID) + +data class FriendJoinRequest(val requestId: UUID) + +enum class FriendActivityKind { + ONLINE, + HOSTING_WORLD, + PLAYING_SERVER, +} + +data class FriendActivity( + val kind: FriendActivityKind, + val description: String? = null, +) + +enum class FriendControlMessageKind { + PAIRING, + REMOVAL, + ACTIVITY, + JOIN, + OTHER, +} + sealed interface FriendControlResponse { data object Received : FriendControlResponse @@ -23,6 +50,12 @@ sealed interface 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 } sealed interface FriendControlDecode { @@ -43,9 +76,14 @@ object FriendControlWire { 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_SERVER_ADDRESS_BYTES = 1_024 fun encodeRequest( request: FriendControlRequest, @@ -107,6 +145,78 @@ object FriendControlWire { } } + fun encodeRemoval(request: FriendRemovalRequest): ByteArray { + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(CONTROL_REMOVAL_PACKET_ID) + writeLong(request.operationId.mostSignificantBits) + writeLong(request.operationId.leastSignificantBits) + } + return output.toByteArray() + } + + fun decodeRemoval( + bytes: ByteArray, + ): FriendControlDecode { + if (bytes.size > MAX_REQUEST_BYTES) { + return FriendControlDecode.Invalid + } + return decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) + val request = FriendRemovalRequest( + UUID(control.readLong(), control.readLong()), + ) + control.ensureFinished() + request + } + } + + 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 = + encodeIdRequest(CONTROL_JOIN_PACKET_ID, request.requestId) + + fun decodeJoinRequest( + bytes: ByteArray, + ): FriendControlDecode = + decodeIdRequest(bytes, CONTROL_JOIN_PACKET_ID) { + FriendJoinRequest(it) + } + + 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() @@ -128,6 +238,19 @@ object FriendControlWire { 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 { @@ -142,6 +265,16 @@ object FriendControlWire { 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) + writeString(response.activity.description.orEmpty()) + } + is FriendControlResponse.JoinAccepted -> { + write(7) + writeString(response.address) + } } } return output.toByteArray() @@ -161,6 +294,22 @@ object FriendControlWire { 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, + description = response.readString(MAX_ACTIVITY_BYTES) + .takeIf(String::isNotEmpty), + ), + ) + } + 7 -> FriendControlResponse.JoinAccepted( + response.readString(MAX_SERVER_ADDRESS_BYTES), + ) else -> invalid() } response.ensureFinished() 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 index fe3d14b6e..0f93d8cf8 100644 --- 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 @@ -59,6 +59,12 @@ data class SavedFriend( "relationshipStatus=$relationshipStatus)" } +data class PendingFriendRemoval( + val operationId: UUID, + val friend: SavedFriend, + val removedAt: Instant, +) + sealed interface FriendStoreError { val safeMessage: String @@ -85,7 +91,7 @@ sealed interface FriendStoreError { class FriendStore( private val directory: Path, ) { - private var cached: List? = null + private var cached: StoreData? = null @Synchronized fun all(): List = @@ -104,6 +110,10 @@ class FriendStore( fun relationship(peerId: String): Option = read().firstOrNull { it.peerId == peerId }.toOption() + @Synchronized + fun pendingRemovals(): List = + data().removals + @Synchronized fun accept( invitationUri: String, @@ -203,7 +213,14 @@ class FriendStore( relationshipStatus = effectiveRelationshipStatus, ) write( - current.filterNot { it.peerId == friend.peerId } + friend, + data().copy( + friends = current.filterNot { + it.peerId == friend.peerId + } + friend, + removals = data().removals.filterNot { + it.friend.peerId == friend.peerId + }, + ), ) friend } @@ -237,13 +254,45 @@ class FriendStore( } @Synchronized - fun remove(peerId: String): Boolean { + 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 } - if (remaining.size == current.size) { + val removals = data().removals.filterNot { + it.friend.peerId == peerId + } + PendingFriendRemoval( + operationId = UUID.randomUUID(), + friend = removed, + removedAt = now, + ) + write(StoreData(remaining, removals)) + return true + } + + @Synchronized + fun applyRemoteRemoval(peerId: String): Boolean { + val current = read() + if (current.none { it.peerId == peerId }) { + return false + } + write(data().copy(friends = current.filterNot { it.peerId == peerId })) + return true + } + + @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(remaining) + write(current.copy(removals = remaining)) return true } @@ -260,20 +309,23 @@ class FriendStore( updated } - private fun read(): List = + private fun read(): List = data().friends + + private fun data(): StoreData = cached ?: load().also { cached = it } - private fun load(): List { + private fun load(): StoreData { Files.createDirectories(directory) if (!Files.exists(friendsFile)) { - return emptyList() + return StoreData() } try { val root = GSON.fromJson( Files.readString(friendsFile), JsonObject::class.java, ) ?: throw IOException("Friends file is empty") - if (root.requiredInt("version") != WIRE_VERSION) { + 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") @@ -287,7 +339,17 @@ class FriendStore( if (friends.map(SavedFriend::peerId).distinct().size != friends.size) { throw IOException("Friends file contains duplicate identities") } - return friends + val removals = if (version >= 2) { + root.getAsJsonArray("pendingRemovals") + ?.map { element -> parseRemoval(element.asJsonObject) } + ?: emptyList() + } else { + emptyList() + } + if (removals.size > MAX_FRIENDS) { + throw IOException("Friends file contains too many removals") + } + return StoreData(friends, removals) } catch (exception: JsonParseException) { throw IOException("Friends file is invalid JSON", exception) } catch (exception: IllegalStateException) { @@ -297,6 +359,19 @@ class FriendStore( } } + private fun parseRemoval(json: JsonObject): PendingFriendRemoval = + PendingFriendRemoval( + operationId = UUID.fromString(json.requiredString("operationId")), + friend = parseFriend( + json.getAsJsonObject("friend") + ?: throw IOException("Removal is missing friend"), + ), + removedAt = Instant.ofEpochMilli( + json.get("removedAtEpochMillis")?.asLong + ?: throw IOException("Removal is missing time"), + ), + ) + private fun parseFriend(json: JsonObject): SavedFriend { val peerId = json.requiredString("peerId") val publicKey = json.requiredString("publicKey") @@ -346,53 +421,64 @@ class FriendStore( } private fun write(friends: List) { - require(friends.size <= MAX_FRIENDS) { + 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" + } Files.createDirectories(directory) val entries = JsonArray() - friends.sortedBy { it.displayName.lowercase() }.forEach { friend -> - entries.add(JsonObject().apply { - addProperty("peerId", friend.peerId) - addProperty("publicKey", friend.publicKeyBase64) - addProperty("shareId", friend.shareId.toString()) - addProperty("capability", friend.capability) - friend.connectAddress?.let { - addProperty("connectAddress", it) - } - addProperty("displayName", friend.displayName) - friend.minecraftUuid?.let { - addProperty("minecraftUuid", it.toString()) - } + 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( - "relationshipStatus", - friend.relationshipStatus.name, - ) - add( - "permissions", - JsonObject().apply { - addProperty( - "notifyWhenOnline", - friend.permissions.notifyWhenOnline, - ) - addProperty( - "canSeeMyWorlds", - friend.permissions.canSeeMyWorlds, - ) - addProperty( - "canJoinAutomatically", - friend.permissions.canJoinAutomatically, - ) - }, + "removedAtEpochMillis", + removal.removedAt.toEpochMilli(), ) + add("friend", removal.friend.toJson()) }) } val root = JsonObject().apply { addProperty("version", WIRE_VERSION) add("friends", entries) + add("pendingRemovals", removals) } writeAtomic(GSON.toJson(root)) - cached = friends.toList() + 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("capability", capability) + connectAddress?.let { addProperty("connectAddress", it) } + 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( + "canJoinAutomatically", + permissions.canJoinAutomatically, + ) + }, + ) } private fun writeAtomic(content: String) { @@ -452,7 +538,8 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" - private const val WIRE_VERSION = 1 + private const val MIN_WIRE_VERSION = 1 + private const val WIRE_VERSION = 2 private const val MAX_FRIENDS = 256 private const val MAX_DISPLAY_NAME_LENGTH = 64 private val GSON = Gson() @@ -483,4 +570,9 @@ class FriendStore( value.length in 16..512 && value.none(Char::isWhitespace) } + + private data class StoreData( + val friends: List = emptyList(), + val removals: List = emptyList(), + ) } 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 index d6613a9d4..0e95cf041 100644 --- 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 @@ -49,6 +49,31 @@ class AdmissionControllerTest { 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() 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 index 94c54f17c..9e7a333a4 100644 --- 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 @@ -113,6 +113,44 @@ class FriendControlChannelHandlerTest { 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()) 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 index 41d70a8e0..7118bb85d 100644 --- 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 @@ -37,6 +37,14 @@ class FriendControlWireTest { FriendControlResponse.Declined, FriendControlResponse.TimedOut, FriendControlResponse.Invalid, + FriendControlResponse.Removed, + FriendControlResponse.Activity( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + ), + FriendControlResponse.JoinAccepted("mc.hypixel.net"), ) responses.forEach { response -> @@ -49,6 +57,48 @@ class FriendControlWireTest { } } + @Test + fun `activity and join requests round trip without exposing a server address`() { + val activity = FriendActivityRequest(REQUEST_ID) + val join = FriendJoinRequest(REQUEST_ID) + + 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 `partial and oversized control frames are never accepted`() { val encoded = FriendControlWire.encodeRequest( 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 index 299367132..39ddc437e 100644 --- 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 @@ -213,13 +213,55 @@ class FriendStoreTest { val store = FriendStore(tempDir) store.accept(signedLink(), "Robin", NOW) - val removed = store.remove(PEER_ID) + 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) + + assertTrue(store.applyRemoteRemoval(PEER_ID)) + assertFalse(store.applyRemoteRemoval(PEER_ID)) + + val reloaded = FriendStore(tempDir) + assertTrue(reloaded.all().isEmpty()) + assertTrue(reloaded.pendingRemovals().isEmpty()) + } + + @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) 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 index 380eda167..8887ee8da 100644 --- 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 @@ -9,10 +9,13 @@ 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.FriendOnlineTracker +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.MinecraftStatusProbe 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 java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -58,6 +61,10 @@ class ConnectShare12111Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world", ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val joinTargetSnapshot = AtomicReference(null) val minecraftVersion = SharedConstants.getCurrentVersion().name() val dataDirectory = FabricLoader.getInstance().configDir @@ -96,6 +103,8 @@ class ConnectShare12111Client : ClientModInitializer { playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + friendJoinTarget = joinTargetSnapshot::get, bridgeFactory = { admission, admissionScope, @@ -124,7 +133,7 @@ class ConnectShare12111Client : ClientModInitializer { ) } }, - guestScreens = { parent, browser -> + guestScreens = { parent, browser, activity -> val parentScreen = parent as Screen client.execute { client.setScreen( @@ -134,6 +143,7 @@ class ConnectShare12111Client : ClientModInitializer { ConnectShareClient.friendsViewModel(), browser = browser, remotePresence = remotePresence, + friendActivity = activity, ), ) } @@ -164,9 +174,8 @@ class ConnectShare12111Client : ClientModInitializer { } } val admissionNotifications = NewAdmissionTracker() - val friendNotifications = FriendOnlineTracker() + val socialNotifications = SocialEventTracker() val admissionToastId = SystemToast.SystemToastId() - val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> val installation = @@ -181,6 +190,20 @@ class ConnectShare12111Client : ClientModInitializer { worldNameSnapshot.set( server?.worldData?.levelName ?: "Minecraft world", ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + activitySnapshot.set( + if (externalServer != null) { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServer.name, + ) + } else { + FriendActivity(FriendActivityKind.ONLINE) + }, + ) ConnectShareClient.integratedWorldChanged( worldAvailable, server, @@ -211,19 +234,18 @@ class ConnectShare12111Client : ClientModInitializer { ), ) } - friendNotifications.update( - remotePresence.state.value, - ).firstOrNull()?.let { friend -> + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + socialNotifications.update(friends.state.value).forEach { event -> SystemToast.add( minecraft.toastManager, - friendToastId, - Component.translatable( - "connect_share.notification.friend_online", - ), - Component.translatable( - "connect_share.notification.friend_online_detail", - friend.displayName, - ), + SystemToast.SystemToastId(), + event.title(), + event.detail(), ) } } @@ -245,4 +267,37 @@ class ConnectShare12111Client : ClientModInitializer { const val PRESENCE_REFRESH_MILLIS = 30_000L val LOGGER: Logger = Logger.getLogger("Connect") } + + 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, + ) + } } 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 index ff8b50e91..5f8136715 100644 --- 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 @@ -4,9 +4,12 @@ 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.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.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -22,6 +25,7 @@ import kotlinx.coroutines.withContext import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.Checkbox 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 @@ -37,6 +41,7 @@ class ShareJoinScreen( 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 @@ -72,6 +77,7 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -92,6 +98,7 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -136,10 +143,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) val state = friends.state.value @@ -154,10 +161,10 @@ class ShareJoinScreen( ) if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.empty"), 82, - ).setMaxWidth(CONTENT_WIDTH), + ), ) } incoming.forEachIndexed { index, request -> @@ -169,7 +176,11 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.incoming_request", + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, request.displayName, request.ingress.displayName(), ), @@ -233,11 +244,33 @@ class ShareJoinScreen( saved.forEachIndexed { index, friend -> val y = 58 + (incoming.size + outgoing.size + index) * 26 + val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), ) + if (actionWidth > 0) { + addRenderableWidget( + Button.builder( + Component.translatable( + if (friend.canRequestJoin) { + "connect_share.friends.request_join" + } else { + "connect_share.join.join" + }, + ), + ) { + if (friend.canRequestJoin) requestToJoin(friend.peerId) + else joinSaved(friend.peerId) + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + } addRenderableWidget( Button.builder( Component.translatable( @@ -300,10 +333,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.add_description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) nameBox = addRenderableWidget( EditBox( @@ -467,7 +500,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.auto_join"), font, - ).pos(width / 2 - 155, 104) + ).pos(width / 2 - 155, 126) .selected(friend.permissions.canJoinAutomatically) .tooltip( Tooltip.create( @@ -478,10 +511,18 @@ class ShareJoinScreen( ) .build(), ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 138) + centered(Component.literal(message), 154) .setMaxWidth(CONTENT_WIDTH), ) } @@ -500,8 +541,7 @@ class ShareJoinScreen( friend.peerId, FriendPermissions( notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, + canSeeMyWorlds = shareWorlds.selected(), canJoinAutomatically = autoJoin.selected(), ), @@ -541,12 +581,12 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable( "connect_share.friends.remove_confirm.message", ), 58, - ).setMaxWidth(CONTENT_WIDTH), + ), ) addRenderableWidget( Button.builder( @@ -623,6 +663,43 @@ class ShareJoinScreen( } } + private fun requestToJoin(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + val target = friends.routeFriendControl( + peerId, + browser, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() + if (target == null) { + joining = false + safeMessage = Component.translatable( + "connect_share.friends.friend_unreachable", + ).string + rebuildWidgets() + return@launch + } + ConnectShareClient.friendRequestClient().requestJoin( + target, + FriendJoinRequest(UUID.randomUUID()), + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = { address -> + connect(GuestJoinTarget.Connect(address)) + }, + ) + } + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -679,7 +756,7 @@ class ShareJoinScreen( val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, - authMode = authMode(), + authMode = DirectP2pAuthMode.OFFLINE, ) val target = targetResult.getOrNull() if (target == null) { @@ -866,6 +943,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + friend.onlineViaLan -> Component.translatable( "connect_share.friends.ready_lan", @@ -880,6 +964,12 @@ class ShareJoinScreen( friend.worldName ?: "", ) + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + friend.connectAvailable -> Component.translatable( "connect_share.friends.saved_connect", @@ -943,6 +1033,17 @@ class ShareJoinScreen( ) } + private fun centeredWrapped( + message: Component, + y: Int, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + private enum class Mode { FRIENDS, ADD, 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 index e85b94ae9..d52af6fda 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", @@ -54,6 +54,7 @@ "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…", @@ -63,6 +64,10 @@ "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.playing_server": "%s · spielt auf %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", @@ -91,6 +96,12 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index fe750872d..48b8239f4 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", + "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "connect_share.friends.copying_my_link": "Creating friend link…", @@ -54,6 +54,7 @@ "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "connect_share.friends.retry_request": "Retry", "connect_share.friends.request_sending": "Sending…", @@ -63,6 +64,10 @@ "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", + "connect_share.friends.request_join": "Request", + "connect_share.friends.playing_server": "%s · playing on %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 friend", "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", @@ -91,6 +96,12 @@ "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 online. Open Friends 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", 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 index 0340b73f2..181a07a99 100644 --- 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 @@ -9,10 +9,13 @@ 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.FriendOnlineTracker +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.MinecraftStatusProbe 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 java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -58,6 +61,10 @@ class ConnectShare262Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world", ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val joinTargetSnapshot = AtomicReference(null) val minecraftVersion = SharedConstants.getCurrentVersion().name() val dataDirectory = FabricLoader.getInstance().configDir @@ -96,6 +103,8 @@ class ConnectShare262Client : ClientModInitializer { playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + friendJoinTarget = joinTargetSnapshot::get, bridgeFactory = { admission, admissionScope, @@ -124,7 +133,7 @@ class ConnectShare262Client : ClientModInitializer { ) } }, - guestScreens = { parent, browser -> + guestScreens = { parent, browser, activity -> val parentScreen = parent as Screen client.execute { client.gui.setScreen( @@ -134,6 +143,7 @@ class ConnectShare262Client : ClientModInitializer { ConnectShareClient.friendsViewModel(), browser = browser, remotePresence = remotePresence, + friendActivity = activity, ), ) } @@ -164,9 +174,8 @@ class ConnectShare262Client : ClientModInitializer { } } val admissionNotifications = NewAdmissionTracker() - val friendNotifications = FriendOnlineTracker() + val socialNotifications = SocialEventTracker() val admissionToastId = SystemToast.SystemToastId() - val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> val installation = @@ -181,6 +190,20 @@ class ConnectShare262Client : ClientModInitializer { worldNameSnapshot.set( server?.worldData?.levelName ?: "Minecraft world", ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + activitySnapshot.set( + if (externalServer != null) { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServer.name, + ) + } else { + FriendActivity(FriendActivityKind.ONLINE) + }, + ) ConnectShareClient.integratedWorldChanged( worldAvailable, server, @@ -211,19 +234,18 @@ class ConnectShare262Client : ClientModInitializer { ), ) } - friendNotifications.update( - remotePresence.state.value, - ).firstOrNull()?.let { friend -> + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + socialNotifications.update(friends.state.value).forEach { event -> SystemToast.add( minecraft.gui.toastManager(), - friendToastId, - Component.translatable( - "connect_share.notification.friend_online", - ), - Component.translatable( - "connect_share.notification.friend_online_detail", - friend.displayName, - ), + SystemToast.SystemToastId(), + event.title(), + event.detail(), ) } } @@ -245,4 +267,37 @@ class ConnectShare262Client : ClientModInitializer { const val PRESENCE_REFRESH_MILLIS = 30_000L val LOGGER: Logger = Logger.getLogger("Connect") } + + 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, + ) + } } 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 index 712194978..0bd775d1b 100644 --- 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 @@ -4,9 +4,12 @@ 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.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.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -22,6 +25,7 @@ import kotlinx.coroutines.withContext import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.Checkbox 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 @@ -37,6 +41,7 @@ class ShareJoinScreen( 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 @@ -72,6 +77,7 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -92,6 +98,7 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -136,10 +143,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) val state = friends.state.value @@ -154,10 +161,10 @@ class ShareJoinScreen( ) if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.empty"), 82, - ).setMaxWidth(CONTENT_WIDTH), + ), ) } incoming.forEachIndexed { index, request -> @@ -169,7 +176,11 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.incoming_request", + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, request.displayName, request.ingress.displayName(), ), @@ -233,11 +244,33 @@ class ShareJoinScreen( saved.forEachIndexed { index, friend -> val y = 58 + (incoming.size + outgoing.size + index) * 26 + val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), ) + if (actionWidth > 0) { + addRenderableWidget( + Button.builder( + Component.translatable( + if (friend.canRequestJoin) { + "connect_share.friends.request_join" + } else { + "connect_share.join.join" + }, + ), + ) { + if (friend.canRequestJoin) requestToJoin(friend.peerId) + else joinSaved(friend.peerId) + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + } addRenderableWidget( Button.builder( Component.translatable( @@ -300,10 +333,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.add_description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) nameBox = addRenderableWidget( EditBox( @@ -467,7 +500,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.auto_join"), font, - ).pos(width / 2 - 155, 104) + ).pos(width / 2 - 155, 126) .selected(friend.permissions.canJoinAutomatically) .tooltip( Tooltip.create( @@ -478,10 +511,18 @@ class ShareJoinScreen( ) .build(), ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 138) + centered(Component.literal(message), 154) .setMaxWidth(CONTENT_WIDTH), ) } @@ -500,8 +541,7 @@ class ShareJoinScreen( friend.peerId, FriendPermissions( notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, + canSeeMyWorlds = shareWorlds.selected(), canJoinAutomatically = autoJoin.selected(), ), @@ -541,12 +581,12 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable( "connect_share.friends.remove_confirm.message", ), 58, - ).setMaxWidth(CONTENT_WIDTH), + ), ) addRenderableWidget( Button.builder( @@ -623,6 +663,43 @@ class ShareJoinScreen( } } + private fun requestToJoin(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + val target = friends.routeFriendControl( + peerId, + browser, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() + if (target == null) { + joining = false + safeMessage = Component.translatable( + "connect_share.friends.friend_unreachable", + ).string + rebuildWidgets() + return@launch + } + ConnectShareClient.friendRequestClient().requestJoin( + target, + FriendJoinRequest(UUID.randomUUID()), + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = { address -> + connect(GuestJoinTarget.Connect(address)) + }, + ) + } + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -679,7 +756,7 @@ class ShareJoinScreen( val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, - authMode = authMode(), + authMode = DirectP2pAuthMode.OFFLINE, ) val target = targetResult.getOrNull() if (target == null) { @@ -865,6 +942,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + friend.onlineViaLan -> Component.translatable( "connect_share.friends.ready_lan", @@ -879,6 +963,12 @@ class ShareJoinScreen( friend.worldName ?: "", ) + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + friend.connectAvailable -> Component.translatable( "connect_share.friends.saved_connect", @@ -942,6 +1032,17 @@ class ShareJoinScreen( ) } + private fun centeredWrapped( + message: Component, + y: Int, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + private enum class Mode { FRIENDS, ADD, 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 index e85b94ae9..d52af6fda 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", @@ -54,6 +54,7 @@ "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…", @@ -63,6 +64,10 @@ "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.playing_server": "%s · spielt auf %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", @@ -91,6 +96,12 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index fe750872d..48b8239f4 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", + "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "connect_share.friends.copying_my_link": "Creating friend link…", @@ -54,6 +54,7 @@ "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "connect_share.friends.retry_request": "Retry", "connect_share.friends.request_sending": "Sending…", @@ -63,6 +64,10 @@ "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", + "connect_share.friends.request_join": "Request", + "connect_share.friends.playing_server": "%s · playing on %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 friend", "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", @@ -91,6 +96,12 @@ "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 online. Open Friends 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", 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 index 5cb772d48..677b672b9 100644 --- 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 @@ -10,7 +10,11 @@ fun interface ConnectShareScreenFactory { } fun interface ConnectShareGuestScreenFactory { - fun open(parent: Any, browser: FabricShareBrowser) + fun open( + parent: Any, + browser: FabricShareBrowser, + activity: FriendActivityMonitor, + ) } data class ConnectShareInstallation( @@ -25,6 +29,7 @@ data class ConnectShareInstallation( val controlPlane: ConnectControlPlane, val directControlPlane: DirectControlPlane, val browser: FabricShareBrowser, + val friendActivity: FriendActivityMonitor, val gateway: ShareConnectionGateway, val ownConnectAddress: String, val screens: ConnectShareScreenFactory, @@ -65,7 +70,11 @@ object ConnectShareClient { @JvmStatic fun openJoinScreen(parent: Any) { installation?.let { installed -> - installed.guestScreens.open(parent, installed.browser) + installed.guestScreens.open( + parent, + installed.browser, + installed.friendActivity, + ) } } 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 index 0d6e6a75f..155b5b1c7 100644 --- 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 @@ -13,16 +13,25 @@ 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.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.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore +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.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.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient @@ -37,6 +46,10 @@ object FabricShareBootstrap { playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, playerDisplayName: () -> String? = { null }, + friendActivity: () -> FriendActivity = { + FriendActivity(FriendActivityKind.ONLINE) + }, + friendJoinTarget: () -> String? = { null }, bridgeFactory: ( AdmissionController, @@ -103,13 +116,14 @@ object FabricShareBootstrap { connectAddress = { ownConnectAddress }, ) val friendCardReceiver = FriendCardReceiver(friendStore) - val friendsViewModel = FriendsViewModel(friendStore) val friendRequestServer = FriendRequestServer( scope = scope, admission = admission, issuer = friendCardIssuer, receiver = friendCardReceiver, friendStore = friendStore, + activity = friendActivity, + joinTarget = friendJoinTarget, ) val gateway = ShareConnectionGateway.bind(friendRequestServer) var browser: FabricShareBrowser? = null @@ -185,6 +199,63 @@ object FabricShareBootstrap { 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(removal.operationId), + ) + }, + ) + } + val friendsViewModel = FriendsViewModel(friendStore) { + scope.launch(Dispatchers.IO) { + removalSync.sync() + } + } + 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, @@ -222,6 +293,7 @@ object FabricShareBootstrap { controlPlane = controlPlane, directControlPlane = directControlPlane, browser = activeBrowser, + friendActivity = activityMonitor, gateway = gateway, ownConnectAddress = ownConnectAddress, screens = screens, @@ -256,6 +328,8 @@ object FabricShareBootstrap { 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 class FabricConnectLogger( 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/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 index edc1c68a4..5f00a80f1 100644 --- 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 @@ -3,10 +3,15 @@ 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 @@ -100,6 +105,15 @@ class FriendRequestClient( FriendControlResponse.Invalid -> outcome = FriendRequestFailure.InvalidResponse.left() + + FriendControlResponse.Removed -> + outcome = + FriendRequestFailure.InvalidResponse.left() + + is FriendControlResponse.Activity, + is FriendControlResponse.JoinAccepted, + -> outcome = + FriendRequestFailure.InvalidResponse.left() } } outcome @@ -116,6 +130,127 @@ class FriendRequestClient( } } + 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, + -> 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 -> response.address.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 { 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 index d5c8f465c..51ab6a691 100644 --- 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 @@ -9,6 +9,11 @@ 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.FriendRelationshipStatus import com.minekube.connect.share.friend.FriendStore @@ -32,6 +37,10 @@ class FriendRequestServer( 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 joinTarget: () -> String? = { null }, ) : FriendControlServer { override fun handle( context: FriendControlContext, @@ -52,6 +61,111 @@ class FriendRequestServer( 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 { + admission.denyDirectPeer( + peerId, + AdmissionPurpose.FRIEND, + ) + if (friendStore.applyRemoteRemoval(peerId)) { + 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 visible = if (friend.permissions.canSeeMyWorlds) { + activity() + } else { + FriendActivity(FriendActivityKind.ONLINE) + } + FriendControlResponse.Activity(visible) + } + + override fun handleJoin( + context: FriendControlContext, + request: FriendJoinRequest, + ): CompletionStage = launchResponse { + val friend = authenticatedFriend(context) + ?: return@launchResponse FriendControlResponse.Invalid + if (activity().kind != FriendActivityKind.PLAYING_SERVER) { + return@launchResponse FriendControlResponse.Invalid + } + val identity = AdmissionIdentity.UnverifiedOffline( + name = friend.displayName, + uuid = friend.shareId, + connectionId = "friend-join:${request.requestId}", + ingress = context.ingress, + directPeerId = context.directPeerId, + ) + when (admission.request(identity, AdmissionPurpose.JOIN)) { + AdmissionAnswer.ALLOW -> joinTarget() + ?.takeIf(String::isNotBlank) + ?.let(FriendControlResponse::JoinAccepted) + ?: 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, 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..6aec8793c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt @@ -0,0 +1,66 @@ +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.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/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt index 34afb59a6..2a1e0be88 100644 --- 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 @@ -18,6 +18,8 @@ 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.tunnel.p2p.DirectP2pAuthMode import java.time.Instant import java.util.UUID @@ -33,6 +35,10 @@ data class FriendSummary( 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, ) data class OutgoingFriendRequestSummary( @@ -44,6 +50,7 @@ data class IncomingFriendRequestSummary( val requestId: UUID, val displayName: String, val ingress: Ingress, + val purpose: AdmissionPurpose, ) data class FriendsUiState( @@ -55,9 +62,11 @@ data class FriendsUiState( class FriendsViewModel( private val store: FriendStore, + 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()) @@ -123,6 +132,9 @@ class FriendsViewModel( }, ifRight = { removed -> refresh() + if (removed) { + onRemovalQueued() + } removed }, ) @@ -145,10 +157,15 @@ class FriendsViewModel( refresh(preserveSafeMessage = true) } + fun updateActivities(activity: Map) { + if (activities == activity) return + activities = activity + refresh(preserveSafeMessage = true) + } + fun updateIncoming(pending: List) { val next = pending .asSequence() - .filter { it.purpose == AdmissionPurpose.FRIEND } .map { IncomingFriendRequestSummary( requestId = it.requestId, @@ -160,6 +177,7 @@ class FriendsViewModel( is AdmissionIdentity.UnverifiedOffline -> identity.ingress }, + purpose = it.purpose, ) } .toList() @@ -192,6 +210,16 @@ class FriendsViewModel( 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() } @@ -254,6 +282,7 @@ class FriendsViewModel( private fun SavedFriend.summary(): FriendSummary { val remote = remotePresence[peerId] ?.takeIf { it.online } + val activity = activities[peerId] return FriendSummary( peerId = peerId, displayName = displayName, @@ -262,6 +291,12 @@ class FriendsViewModel( onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, onlineViaConnect = remote?.route == ShareRoute.CONNECT, worldName = remote?.description, + activityKind = activity?.kind, + activityDescription = activity?.description, + canRequestJoin = + activity?.kind == FriendActivityKind.PLAYING_SERVER, + canJoinNow = remote != null && + activity?.kind != FriendActivityKind.PLAYING_SERVER, ) } 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/FriendPairingDirectE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt index 40f455d6f..07bfd97ff 100644 --- 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 @@ -6,6 +6,11 @@ import com.minekube.connect.share.ShareOptions import com.minekube.connect.share.admission.AdmissionController 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 @@ -64,6 +69,13 @@ class FriendPairingDirectE2ETest { friendStore = hostStore, now = { now }, ioDispatcher = Dispatchers.IO, + activity = { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ) + }, + joinTarget = { "mc.hypixel.net" }, ) try { @@ -102,17 +114,18 @@ class FriendPairingDirectE2ETest { 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 = FriendRequestClient( - ioDispatcher = Dispatchers.IO, - connectTimeout = Duration.ofSeconds(3), - decisionTimeout = Duration.ofSeconds(5), - ), + requestClient = requestClient, now = { now }, ioDispatcher = Dispatchers.IO, ) @@ -155,6 +168,69 @@ class FriendPairingDirectE2ETest { "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()), + ) + } + 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( + "mc.hypixel.net", + requestedJoin.await().getOrNull(), + ) + + 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(removal.operationId), + ).isRight(), + ) + senderStore.acknowledgeRemoval(removal.operationId) + + assertTrue(hostStore.all().isEmpty()) + assertTrue(senderStore.all().isEmpty()) + assertTrue(senderStore.pendingRemovals().isEmpty()) } finally { browser.close() direct.close() 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 index afcd5df0a..2ff84f62c 100644 --- 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 @@ -6,6 +6,11 @@ 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 @@ -127,6 +132,91 @@ class FriendRequestClientTest { 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()) + val server = responseServer( + FriendControlWire.encodeJoinRequest(request), + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + ) + + val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) + .requestJoin(directTarget(server), request) + + assertEquals("mc.hypixel.net", 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) { 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 index 7287cb41d..2c7c72df2 100644 --- 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 @@ -7,6 +7,11 @@ 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 java.nio.file.Path import java.time.Instant @@ -149,6 +154,130 @@ class FriendRequestServerTest { 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 server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val context = FriendControlContext( + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ) + val removal = FriendRemovalRequest(UUID.randomUUID()) + + assertEquals( + FriendControlResponse.Removed, + server.handleRemoval(context, removal).await(), + ) + assertEquals( + FriendControlResponse.Removed, + server.handleRemoval(context, removal).await(), + ) + assertTrue(hostStore.all().isEmpty()) + assertTrue(hostStore.pendingRemovals().isEmpty()) + } + + @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()), + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + assertEquals(AdmissionPurpose.JOIN, pending.purpose) + assertEquals("bob", pending.identity.name) + admission.answer(pending.requestId, allow = true) + runCurrent() + + assertEquals( + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + response.getNow(null), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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..e2e424cd0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt @@ -0,0 +1,55 @@ +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()), + ) + } + + 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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt index 8f060b043..9af99d374 100644 --- 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 @@ -16,6 +16,8 @@ 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 @@ -94,7 +96,7 @@ class FriendsViewModelTest { } @Test - fun `title friends state exposes only incoming friend approvals`() { + fun `friends state exposes both friend and join approvals`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) val friendRequestId = UUID.randomUUID() val joinRequestId = UUID.randomUUID() @@ -124,10 +126,14 @@ class FriendsViewModelTest { ), ) - val incoming = viewModel.state.value.incomingRequests.single() - assertEquals(friendRequestId, incoming.requestId) - assertEquals("bob", incoming.displayName) - assertEquals(Ingress.CONNECT, incoming.ingress) + 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()) } @@ -285,6 +291,28 @@ class FriendsViewModelTest { assertEquals("Robin's Remote World", online.worldName) } + @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 `joining a saved friend does not expose its stored capability`() = runTest { val link = signedLink() From 323c1e753e3bb9ca1728e3d42b36c901e29bb245 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 16:45:26 +0200 Subject: [PATCH 040/188] feat(share): request joins to friends worlds --- share/AGENTS.md | 43 ++++++ .../connect/share/ShareConnectionGateway.kt | 10 ++ .../share/admission/AdmissionController.kt | 32 +++++ .../connect/share/friend/FriendControlWire.kt | 47 ++++++- .../share/ShareConnectionGatewayTest.kt | 9 ++ .../admission/AdmissionControllerTest.kt | 46 +++++++ .../share/friend/FriendControlWireTest.kt | 9 +- .../mixin/ServerLoginPacketListenerMixin.java | 10 ++ .../v1_21_11/ConnectShare12111Client.kt | 22 +-- .../fabric/v1_21_11/FriendCardNetworking.kt | 1 + .../v1_21_11/Minecraft12111LoginBridge.kt | 16 +++ .../share/fabric/v1_21_11/ShareJoinScreen.kt | 34 ++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../mixin/ServerLoginPacketListenerMixin.java | 10 ++ .../fabric/v26_2/ConnectShare262Client.kt | 22 +-- .../fabric/v26_2/FriendCardNetworking.kt | 1 + .../fabric/v26_2/Minecraft262LoginBridge.kt | 16 +++ .../share/fabric/v26_2/ShareJoinScreen.kt | 34 ++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../FabricDirectAuthenticationPolicy.kt | 13 ++ .../share/fabric/FriendActivityResolver.kt | 23 ++++ .../connect/share/fabric/FriendCardIssuer.kt | 11 +- .../share/fabric/FriendRequestClient.kt | 15 ++- .../share/fabric/FriendRequestServer.kt | 26 +++- .../share/fabric/SocialEventTracker.kt | 8 ++ .../share/fabric/ui/FriendsViewModel.kt | 7 +- .../FabricDirectAuthenticationPolicyTest.kt | 21 +++ .../fabric/FriendActivityResolverTest.kt | 47 +++++++ .../share/fabric/FriendCardIssuerTest.kt | 1 + .../fabric/FriendPairingDirectE2ETest.kt | 73 ++++++++-- .../share/fabric/FriendRequestClientTest.kt | 34 ++++- .../share/fabric/FriendRequestServerTest.kt | 59 ++++++++- .../share/fabric/PrismFriendJoinE2ETest.kt | 125 ++++++++++++++++++ .../share/fabric/SocialEventTrackerTest.kt | 23 ++++ .../share/fabric/ui/FriendsViewModelTest.kt | 33 +++++ 37 files changed, 826 insertions(+), 67 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityResolverTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt diff --git a/share/AGENTS.md b/share/AGENTS.md index 090dd4704..9e1d69952 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -64,3 +64,46 @@ redesigned for Kotlin. cancellation. - For retries or parallel operators, use deterministic virtual-time tests; no real sleeps. + +## Prism Two-Client E2E + +- Prism can drive the live flow without UI automation. Launch the host with + `prismlauncher --launch --profile --world ` and a + distinct offline guest with + `prismlauncher --launch --offline --server `. + `--offline ` is authoritative; editing `InstanceAccountId` while Prism + runs is not, because Prism rewrites it. +- Prove the flow in layers: mDNS discovery, authenticated friend activity, + Minecraft status, then a real login whose host log contains + ` joined the game`. Control-plane reachability or a status response does + not prove that the world is joinable. `dns-sd -B + _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are + useful diagnostics for discovery and live `ShareState`/transport objects. +- Run only one Gradle invocation at a time in a worktree. Concurrent test tasks + share `build/test-results` and can delete one another's in-progress binary + results, producing a false infrastructure failure. +- A `DirectP2pProxy` target is currently one-shot. A status probe consumes it; + open a separate target for gameplay and keep that target alive until the + Minecraft connection finishes. Never reuse the friend-control target for a + status probe or login. +- 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`; otherwise friends see a world that cannot yet + accept them. +- `ShareConnectionGateway` installs Minecraft's captured Netty initializer + after its accepted channel is already active. Any change to that dispatch + must preserve a focused test proving newly installed handlers receive the + required active lifecycle before the first Minecraft bytes. +- A direct session negotiated as `OFFLINE` must create Minecraft's standard + offline profile in `handleHello`, before vanilla starts Mojang session + authentication. Otherwise an offline Prism friend is rejected as "Invalid + session" before admission runs. `ONLINE` direct sessions must never silently + downgrade. +- For no-click friend-request E2E, temporarily enable automatic joins only for + the confirmed test friend, send the real libp2p join request, and restore the + permission afterwards. Keep machine-specific instance paths and credentials + in environment variables, never in committed tests or scripts. +- `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, + supply `LIVE_DATA`, `LIVE_PORT_FILE`, and `LIVE_HOST_LOG`, then launch the + guest against the port written to `LIVE_PORT_FILE`. The test succeeds only + after the host logs a new ` joined the game` line. 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 index 07dafaa00..6e57abf04 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -153,7 +153,15 @@ class ShareConnectionGateway private constructor( } 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) } } @@ -180,5 +188,7 @@ class ShareConnectionGateway private constructor( "connect-share-minecraft-dispatch" private const val MINECRAFT_INITIALIZER = "connect-share-minecraft-initializer" + private const val MINECRAFT_LIFECYCLE_REPLAY = + "connect-share-minecraft-lifecycle-replay" } } 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 index c587c61b4..8dcaa6da4 100644 --- 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 @@ -24,6 +24,7 @@ class AdmissionController( private val lock = Any() private val requests = linkedMapOf() private val authenticatedApprovals = mutableSetOf() + private val preapprovedJoins = mutableSetOf() private val mutablePending = MutableStateFlow>(emptyList()) val pending: StateFlow> = mutablePending.asStateFlow() @@ -49,6 +50,17 @@ class AdmissionController( ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } + if (purpose == AdmissionPurpose.JOIN) { + val preapproved = preapprovedJoins.firstOrNull { + it.matches(identity) + } + if (preapproved != null) { + preapprovedJoins.remove(preapproved) + return@synchronized RequestLookup.Immediate( + AdmissionAnswer.ALLOW, + ) + } + } if ( purpose == AdmissionPurpose.JOIN && autoApprove(identity) @@ -121,6 +133,7 @@ class AdmissionController( purpose: AdmissionPurpose, ): Int { val denied = synchronized(lock) { + preapprovedJoins.removeIf { it.directPeerId == peerId } val matches = requests.entries.filter { entry -> entry.value.pending.purpose == purpose && entry.value.pending.identity.directPeerId == peerId @@ -138,6 +151,7 @@ class AdmissionController( val current = requests.values.toList() requests.clear() authenticatedApprovals.clear() + preapprovedJoins.clear() publishPending() current } @@ -146,6 +160,15 @@ class AdmissionController( } } + fun approveNextJoin(identity: AdmissionIdentity) { + synchronized(lock) { + preapprovedJoins += PreapprovedJoin( + directPeerId = identity.directPeerId, + minecraftUuid = identity.uuid, + ) + } + } + private fun startTimeout(request: PendingRequest) { val timeoutJob = scope.launch { delay(timeout) @@ -222,6 +245,15 @@ class AdmissionController( ) : AdmissionKey } + private data class PreapprovedJoin( + val directPeerId: String?, + val minecraftUuid: UUID, + ) { + fun matches(identity: AdmissionIdentity): Boolean = + (directPeerId != null && directPeerId == identity.directPeerId) || + minecraftUuid == identity.uuid + } + private class PendingRequest( val key: AdmissionKey, val pending: PendingAdmission, 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 index e253ff792..e8d2a1f3b 100644 --- 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 @@ -17,7 +17,11 @@ data class FriendRemovalRequest( data class FriendActivityRequest(val requestId: UUID) -data class FriendJoinRequest(val requestId: UUID) +data class FriendJoinRequest( + val requestId: UUID, + val playerName: String, + val playerUuid: UUID, +) enum class FriendActivityKind { ONLINE, @@ -56,6 +60,8 @@ sealed interface FriendControlResponse { data class Activity(val activity: FriendActivity) : FriendControlResponse data class JoinAccepted(val address: String) : FriendControlResponse + + data object SharedWorldJoinAccepted : FriendControlResponse } sealed interface FriendControlDecode { @@ -84,6 +90,7 @@ object FriendControlWire { private const val MAX_INVITATION_BYTES = 32_768 private const val MAX_ACTIVITY_BYTES = 512 private const val MAX_SERVER_ADDRESS_BYTES = 1_024 + private const val MAX_PLAYER_NAME_BYTES = 64 fun encodeRequest( request: FriendControlRequest, @@ -182,15 +189,41 @@ object FriendControlWire { FriendActivityRequest(it) } - fun encodeJoinRequest(request: FriendJoinRequest): ByteArray = - encodeIdRequest(CONTROL_JOIN_PACKET_ID, request.requestId) + 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 = - decodeIdRequest(bytes, CONTROL_JOIN_PACKET_ID) { - FriendJoinRequest(it) + ): 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() @@ -275,6 +308,7 @@ object FriendControlWire { write(7) writeString(response.address) } + FriendControlResponse.SharedWorldJoinAccepted -> write(8) } } return output.toByteArray() @@ -310,6 +344,7 @@ object FriendControlWire { 7 -> FriendControlResponse.JoinAccepted( response.readString(MAX_SERVER_ADDRESS_BYTES), ) + 8 -> FriendControlResponse.SharedWorldJoinAccepted else -> invalid() } response.ensureFinished() 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 index 20cdab1c9..80bae656c 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -85,11 +85,19 @@ class ShareConnectionGatewayTest { 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, @@ -128,6 +136,7 @@ class ShareConnectionGatewayTest { ORDINARY_MINECRAFT_BYTES, received.get(2, TimeUnit.SECONDS), ) + assertEquals(Unit, activated.get(2, TimeUnit.SECONDS)) } Socket().use { socket -> 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 index 0e95cf041..e0acd2b17 100644 --- 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 @@ -253,6 +253,52 @@ class AdmissionControllerTest { 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 also authorizes Connect fallback by player UUID`() = 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( + authenticated("RoboFlax2", AUTHENTICATED_UUID), + ), + ) + assertTrue(controller.pending.value.isEmpty()) + } + private fun kotlinx.coroutines.test.TestScope.controller( connectedCount: () -> Int = { 0 }, maxGuests: () -> Int = { 8 }, 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 index 7118bb85d..c210904ac 100644 --- 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 @@ -45,6 +45,7 @@ class FriendControlWireTest { ), ), FriendControlResponse.JoinAccepted("mc.hypixel.net"), + FriendControlResponse.SharedWorldJoinAccepted, ) responses.forEach { response -> @@ -60,7 +61,11 @@ class FriendControlWireTest { @Test fun `activity and join requests round trip without exposing a server address`() { val activity = FriendActivityRequest(REQUEST_ID) - val join = FriendJoinRequest(REQUEST_ID) + val join = FriendJoinRequest( + requestId = REQUEST_ID, + playerName = "RoboFlax2", + playerUuid = PLAYER_UUID, + ) assertEquals( activity, @@ -122,5 +127,7 @@ class FriendControlWireTest { 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") } } 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 index e68d19668..d546be367 100644 --- 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 @@ -36,6 +36,16 @@ public abstract class ServerLoginPacketListenerMixin { 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; } 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 index 8887ee8da..2ab42f875 100644 --- 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 @@ -9,10 +9,12 @@ 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.MinecraftStatusProbe +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 @@ -182,7 +184,7 @@ class ConnectShare12111Client : ClientModInitializer { installationReference.get() ?: return@register val server = minecraft.singleplayerServer - val worldAvailable = minecraft.hasSingleplayerServer() + val worldAvailable = server != null && minecraft.connection != null worldAvailableSnapshot.set(worldAvailable) playerCountSnapshot.set( server?.playerList?.playerCount ?: 0, @@ -195,14 +197,13 @@ class ConnectShare12111Client : ClientModInitializer { ?.takeIf { !worldAvailable } joinTargetSnapshot.set(externalServer?.ip) activitySnapshot.set( - if (externalServer != null) { - FriendActivity( - FriendActivityKind.PLAYING_SERVER, - externalServer.name, - ) - } else { - FriendActivity(FriendActivityKind.ONLINE) - }, + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + ), ) ConnectShareClient.integratedWorldChanged( worldAvailable, @@ -264,7 +265,7 @@ class ConnectShare12111Client : ClientModInitializer { } private companion object { - const val PRESENCE_REFRESH_MILLIS = 30_000L + const val PRESENCE_REFRESH_MILLIS = 10_000L val LOGGER: Logger = Logger.getLogger("Connect") } @@ -298,6 +299,7 @@ class ConnectShare12111Client : ClientModInitializer { 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/FriendCardNetworking.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt index ddd793d95..294da7970 100644 --- 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 @@ -41,6 +41,7 @@ object FriendCardNetworking { displayName = player.gameProfile.name(), authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, ) } } 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 index 8974cff9e..50cb67665 100644 --- 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 @@ -7,6 +7,7 @@ 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.DirectOnlineAuthenticationRequired +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.tunnel.p2p.DirectP2pRoute @@ -16,6 +17,8 @@ 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 @@ -50,6 +53,19 @@ object Minecraft12111LoginBridge { 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, 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 index 5f8136715..87f039212 100644 --- 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 @@ -3,6 +3,7 @@ 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.FriendJoinApproval import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.FriendActivityMonitor import com.minekube.connect.share.fabric.GuestJoinTarget @@ -686,20 +687,40 @@ class ShareJoinScreen( } ConnectShareClient.friendRequestClient().requestJoin( target, - FriendJoinRequest(UUID.randomUUID()), + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), ).fold( ifLeft = { failure -> joining = false safeMessage = failure.safeMessage rebuildWidgets() }, - ifRight = { address -> - connect(GuestJoinTarget.Connect(address)) + ifRight = { approval -> + when (approval) { + is FriendJoinApproval.ExternalServer -> + connect(GuestJoinTarget.Connect(approval.address)) + FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + } }, ) } } + private suspend fun joinApprovedWorld(peerId: String) { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -943,6 +964,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> Component.translatable( "connect_share.friends.playing_server", 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 index d52af6fda..c5885433f 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Verwalten", "connect_share.friends.request_join": "Anfragen", "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", @@ -95,7 +96,7 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index 48b8239f4..3daee1fb2 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Manage", "connect_share.friends.request_join": "Request", "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 friend", @@ -95,7 +96,7 @@ "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 online. Open Friends to join.", + "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", 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 index ff9741385..faaa8f7b7 100644 --- 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 @@ -38,6 +38,16 @@ private void startClientVerification(GameProfile profile) { 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; } 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 index 181a07a99..c45b4a9aa 100644 --- 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 @@ -9,10 +9,12 @@ 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.MinecraftStatusProbe +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 @@ -182,7 +184,7 @@ class ConnectShare262Client : ClientModInitializer { installationReference.get() ?: return@register val server = minecraft.singleplayerServer - val worldAvailable = minecraft.hasSingleplayerServer() + val worldAvailable = server != null && minecraft.connection != null worldAvailableSnapshot.set(worldAvailable) playerCountSnapshot.set( server?.playerList?.playerCount ?: 0, @@ -195,14 +197,13 @@ class ConnectShare262Client : ClientModInitializer { ?.takeIf { !worldAvailable } joinTargetSnapshot.set(externalServer?.ip) activitySnapshot.set( - if (externalServer != null) { - FriendActivity( - FriendActivityKind.PLAYING_SERVER, - externalServer.name, - ) - } else { - FriendActivity(FriendActivityKind.ONLINE) - }, + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + ), ) ConnectShareClient.integratedWorldChanged( worldAvailable, @@ -264,7 +265,7 @@ class ConnectShare262Client : ClientModInitializer { } private companion object { - const val PRESENCE_REFRESH_MILLIS = 30_000L + const val PRESENCE_REFRESH_MILLIS = 10_000L val LOGGER: Logger = Logger.getLogger("Connect") } @@ -298,6 +299,7 @@ class ConnectShare262Client : ClientModInitializer { 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/FriendCardNetworking.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt index fe7ee0db0..ad5464aa4 100644 --- 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 @@ -41,6 +41,7 @@ object FriendCardNetworking { displayName = player.gameProfile.name(), authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, ) } } 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 index a68f2b899..8820664b4 100644 --- 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 @@ -7,6 +7,7 @@ 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.DirectOnlineAuthenticationRequired +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.tunnel.p2p.DirectP2pRoute @@ -16,6 +17,8 @@ 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 @@ -50,6 +53,19 @@ object Minecraft262LoginBridge { 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, 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 index 0bd775d1b..0321f2f33 100644 --- 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 @@ -3,6 +3,7 @@ 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.FriendJoinApproval import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.FriendActivityMonitor import com.minekube.connect.share.fabric.GuestJoinTarget @@ -686,20 +687,40 @@ class ShareJoinScreen( } ConnectShareClient.friendRequestClient().requestJoin( target, - FriendJoinRequest(UUID.randomUUID()), + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), ).fold( ifLeft = { failure -> joining = false safeMessage = failure.safeMessage rebuildWidgets() }, - ifRight = { address -> - connect(GuestJoinTarget.Connect(address)) + ifRight = { approval -> + when (approval) { + is FriendJoinApproval.ExternalServer -> + connect(GuestJoinTarget.Connect(approval.address)) + FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + } }, ) } } + private suspend fun joinApprovedWorld(peerId: String) { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -942,6 +963,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> Component.translatable( "connect_share.friends.playing_server", 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 index d52af6fda..c5885433f 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Verwalten", "connect_share.friends.request_join": "Anfragen", "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", @@ -95,7 +96,7 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index 48b8239f4..3daee1fb2 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Manage", "connect_share.friends.request_join": "Request", "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 friend", @@ -95,7 +96,7 @@ "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 online. Open Friends to join.", + "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", 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 index 28284409c..722cc2245 100644 --- 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 @@ -10,7 +10,20 @@ data object DirectOnlineAuthenticationRequired { "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, 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..c1707ba5f --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt @@ -0,0 +1,23 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind + +object FriendActivityResolver { + fun resolve( + worldAvailable: Boolean, + worldSharingActive: Boolean, + worldName: String?, + externalServerName: String?, + ): FriendActivity = when { + externalServerName != null -> FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServerName, + ) + worldAvailable && worldSharingActive -> FriendActivity( + FriendActivityKind.HOSTING_WORLD, + worldName?.takeIf(String::isNotBlank) ?: "Minecraft world", + ) + else -> FriendActivity(FriendActivityKind.ONLINE) + } +} 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 index 220414f43..01ecf78d8 100644 --- 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 @@ -30,13 +30,14 @@ class FriendCardReceiver( invitation: String, displayName: String, authenticatedMinecraftUuid: UUID?, + allowAutomaticJoin: Boolean = false, now: Instant = Instant.now(), ): Either = - store.acceptAndAllowJoin( - invitation, - displayName, - now, - ).flatMap { friend -> + (if (allowAutomaticJoin) { + store.acceptAndAllowJoin(invitation, displayName, now) + } else { + store.accept(invitation, displayName, now) + }).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> store.linkMinecraftIdentity( friend.peerId, 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 index 5f00a80f1..d85457ac6 100644 --- 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 @@ -49,6 +49,12 @@ sealed interface FriendRequestFailure { } } +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), @@ -112,6 +118,7 @@ class FriendRequestClient( is FriendControlResponse.Activity, is FriendControlResponse.JoinAccepted, + FriendControlResponse.SharedWorldJoinAccepted, -> outcome = FriendRequestFailure.InvalidResponse.left() } @@ -162,6 +169,7 @@ class FriendRequestClient( is FriendControlResponse.Accepted, is FriendControlResponse.Activity, is FriendControlResponse.JoinAccepted, + FriendControlResponse.SharedWorldJoinAccepted, -> return@withContext FriendRequestFailure.InvalidResponse.left() } } @@ -199,13 +207,16 @@ class FriendRequestClient( suspend fun requestJoin( target: GuestJoinTarget.Direct, request: FriendJoinRequest, - ): Either = + ): Either = exchangeControl( target, FriendControlWire.encodeJoinRequest(request), ).flatMap { response -> when (response) { - is FriendControlResponse.JoinAccepted -> response.address.right() + 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() 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 index 51ab6a691..c30378f18 100644 --- 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 @@ -115,21 +115,33 @@ class FriendRequestServer( ): CompletionStage = launchResponse { val friend = authenticatedFriend(context) ?: return@launchResponse FriendControlResponse.Invalid - if (activity().kind != FriendActivityKind.PLAYING_SERVER) { + 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 = friend.displayName, - uuid = friend.shareId, + 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 -> joinTarget() - ?.takeIf(String::isNotBlank) - ?.let(FriendControlResponse::JoinAccepted) - ?: FriendControlResponse.Invalid + 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, 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 index 6aec8793c..0f97f2ad3 100644 --- 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 @@ -49,6 +49,14 @@ class SocialEventTracker { 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, 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 index 2a1e0be88..360eb282b 100644 --- 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 @@ -294,9 +294,12 @@ class FriendsViewModel( activityKind = activity?.kind, activityDescription = activity?.description, canRequestJoin = - activity?.kind == FriendActivityKind.PLAYING_SERVER, + activity?.kind == FriendActivityKind.PLAYING_SERVER || + activity?.kind == FriendActivityKind.HOSTING_WORLD && + remote != null, canJoinNow = remote != null && - activity?.kind != FriendActivityKind.PLAYING_SERVER, + activity?.kind != FriendActivityKind.PLAYING_SERVER && + activity?.kind != FriendActivityKind.HOSTING_WORLD, ) } 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 index 809a8046b..895f523fd 100644 --- 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 @@ -3,6 +3,7 @@ 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 { @@ -25,4 +26,24 @@ class FabricDirectAuthenticationPolicyTest { ), ) } + + @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/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/FriendCardIssuerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt index 668f7c1ff..92eccd392 100644 --- 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 @@ -77,6 +77,7 @@ class FriendCardIssuerTest { invitation = card, displayName = "Robin", authenticatedMinecraftUuid = minecraftUuid, + allowAutomaticJoin = true, now = NOW, ) 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 index 07bfd97ff..e5c6bb5fd 100644 --- 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 @@ -4,6 +4,10 @@ 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 @@ -59,6 +63,12 @@ class FriendPairingDirectE2ETest { connectedCount = { 0 }, maxGuests = { 8 }, ) + val hostActivity = AtomicReference( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + ) val hostServer = FriendRequestServer( scope = this, admission = admission, @@ -69,12 +79,7 @@ class FriendPairingDirectE2ETest { friendStore = hostStore, now = { now }, ioDispatcher = Dispatchers.IO, - activity = { - FriendActivity( - FriendActivityKind.PLAYING_SERVER, - "Hypixel", - ) - }, + activity = hostActivity::get, joinTarget = { "mc.hypixel.net" }, ) @@ -195,7 +200,11 @@ class FriendPairingDirectE2ETest { val requestedJoin = async { requestClient.requestJoin( joinTarget, - FriendJoinRequest(java.util.UUID.randomUUID()), + FriendJoinRequest( + java.util.UUID.randomUUID(), + "bob", + java.util.UUID.randomUUID(), + ), ) } val joinAdmission = withTimeout(5.seconds) { @@ -206,10 +215,58 @@ class FriendPairingDirectE2ETest { } admission.answer(joinAdmission.requestId, allow = true) assertEquals( - "mc.hypixel.net", + 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() 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 index 2ff84f62c..facd44fc8 100644 --- 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 @@ -181,7 +181,11 @@ class FriendRequestClientTest { @Test fun `join request returns address only after remote acceptance`() = runBlocking { - val request = FriendJoinRequest(UUID.randomUUID()) + val request = FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ) val server = responseServer( FriendControlWire.encodeJoinRequest(request), FriendControlResponse.JoinAccepted("mc.hypixel.net"), @@ -190,7 +194,31 @@ class FriendRequestClientTest { val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) .requestJoin(directTarget(server), request) - assertEquals("mc.hypixel.net", assertIs>(result).value) + 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( @@ -257,5 +285,7 @@ class FriendRequestClientTest { 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 index 2c7c72df2..8d71d47dc 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -74,7 +75,7 @@ class FriendRequestServerTest { ShareInviteCodec.decode(accepted.invitation, NOW).isRight(), ) assertEquals(senderPeerId, hostStore.all().single().peerId) - assertTrue(hostStore.all().single().permissions.canJoinAutomatically) + assertFalse(hostStore.all().single().permissions.canJoinAutomatically) } @Test @@ -149,7 +150,7 @@ class FriendRequestServerTest { assertTrue(admission.pending.value.isEmpty()) val confirmed = hostStore.all().single() assertEquals(senderPeerId, confirmed.peerId) - assertTrue(confirmed.permissions.canJoinAutomatically) + assertFalse(confirmed.permissions.canJoinAutomatically) assertTrue(hostStore.outgoingRequests().isEmpty()) assertEquals(1, relationshipsChanged) } @@ -262,13 +263,18 @@ class FriendRequestServerTest { ) val response = server.handleJoin( FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), - FriendJoinRequest(UUID.randomUUID()), + FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ), ).toCompletableFuture() runCurrent() val pending = admission.pending.value.single() assertEquals(AdmissionPurpose.JOIN, pending.purpose) - assertEquals("bob", pending.identity.name) + assertEquals("RoboFlax2", pending.identity.name) + assertEquals(PLAYER_UUID, pending.identity.uuid) admission.answer(pending.requestId, allow = true) runCurrent() @@ -278,6 +284,49 @@ class FriendRequestServerTest { ) } + @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), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, @@ -302,5 +351,7 @@ class FriendRequestServerTest { 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/PrismFriendJoinE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt new file mode 100644 index 000000000..70bab6180 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt @@ -0,0 +1,125 @@ +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 java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assumptions.assumeTrue + +/** + * 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 { + @Test + fun `saved friend requests and joins a live singleplayer world`() = + runBlocking { + val dataValue = System.getenv("LIVE_DATA") + val portValue = System.getenv("LIVE_PORT_FILE") + val hostLogValue = System.getenv("LIVE_HOST_LOG") + assumeTrue( + dataValue != null && portValue != null && hostLogValue != null, + "LIVE_DATA, LIVE_PORT_FILE, and LIVE_HOST_LOG enable this E2E", + ) + val dataDirectory = Path.of(checkNotNull(dataValue)) + val portFile = Path.of(checkNotNull(portValue)) + val hostLog = Path.of(checkNotNull(hostLogValue)) + val playerName = System.getenv("LIVE_PLAYER_NAME") ?: "bob" + val joinedLine = "] $playerName joined the game" + val joinsBefore = Files.readString(hostLog) + .lineSequence() + .count { joinedLine in it } + val friend = FriendStore(dataDirectory).all().single() + 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()!! + assertEquals( + FriendActivityKind.HOSTING_WORLD, + activityTarget.use { + client.activity( + it, + com.minekube.connect.share.friend + .FriendActivityRequest(UUID.randomUUID()), + ).getOrNull()?.kind + }, + ) + + // Status and gameplay require different one-shot proxies. + assertTrue( + browser.probeLan( + friend, + DirectP2pAuthMode.OFFLINE, + MinecraftStatusProbe(), + ) != null, + ) + 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() + }, + ) + val gameplay = assertIs( + browser.join( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull(), + ) + gameplay.use { + Files.writeString( + portFile, + gameplay.localAddress.port.toString(), + ) + withTimeout(180_000) { + while (Files.readString(hostLog) + .lineSequence() + .count { joinedLine in it } <= joinsBefore + ) { + delay(100) + } + } + } + } finally { + browser.close() + } + } +} 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 index e2e424cd0..86dcbc7c7 100644 --- 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 @@ -46,6 +46,29 @@ class SocialEventTrackerTest { ) } + @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", 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 index 9af99d374..9571308ed 100644 --- 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 @@ -313,6 +313,39 @@ class FriendsViewModelTest { assertFalse(friend.canJoinNow) } + @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 `joining a saved friend does not expose its stored capability`() = runTest { val link = signedLink() From fc751a69695dc7904033337abf21a0fa1af07faa Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 17:40:02 +0200 Subject: [PATCH 041/188] docs(share): design Prism E2E skill --- ...-07-31-connect-share-prism-skill-design.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md 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..8451a5f22 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md @@ -0,0 +1,59 @@ +# 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, 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 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. From 8e1c3b395ca464f54601c6dc6cbdcf823642046b Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 17:55:14 +0200 Subject: [PATCH 042/188] docs(share): add Prism E2E agent skill --- .../skills/connect-share-prism-e2e/SKILL.md | 139 ++++++++++++++++++ .../agents/openai.yaml | 4 + 2 files changed, 143 insertions(+) create mode 100644 .agents/skills/connect-share-prism-e2e/SKILL.md create mode 100644 .agents/skills/connect-share-prism-e2e/agents/openai.yaml 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..e94796bec --- /dev/null +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -0,0 +1,139 @@ +--- +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, status, approval, and Minecraft login as +separate gates; success at an earlier gate never proves a later one. + +## 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. + +Confirm each fresh `latest.log` contains both Fabric Loader startup and a +`connect-share` mod entry. Fabric Language Kotlin is packaged as a declared 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 `Published LAN +server`. 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`. + +## 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_PORT_FILE= \ +LIVE_HOST_LOG= \ +LIVE_PLAYER_NAME= \ +./gradlew :share:fabric-common:test \ + --tests '*PrismFriendJoinE2ETest*' --no-parallel +``` + +The test must remain running while the external guest uses the port written to +`LIVE_PORT_FILE`. It proves, in order: + +1. mDNS discovers the saved confirmed friend's peer identity. +2. Authenticated friend control reports `HOSTING_WORLD`. +3. A dedicated direct proxy answers a real Minecraft status probe. +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. + +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. + +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. + +## 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. +- **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. 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 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. + +## 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." From 51af5b77c70c0bf8a3c4faeccb2425a0638877d2 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Fri, 31 Jul 2026 23:46:45 +0200 Subject: [PATCH 043/188] no-mistakes(review): Fix Connect Share review findings --- .../v1_21_11/ConnectShare12111Client.kt | 3 - .../v26_2/mixin/IntegratedServerMixin.java | 2 +- .../fabric/v26_2/ConnectShare262Client.kt | 3 - .../fabric/v26_2/Fabric262ArtifactTest.kt | 22 ++++++ .../share/fabric/ConnectControlPlane.kt | 8 ++ .../share/fabric/ConnectShareClient.kt | 4 +- .../share/fabric/FabricDirectPeerRuntime.kt | 15 ++-- .../share/fabric/FabricDirectShareIngress.kt | 6 +- .../share/fabric/FabricShareBootstrap.kt | 58 ++++++++------ .../connect/share/fabric/FriendCardIssuer.kt | 6 +- .../share/fabric/FriendPresenceMonitor.kt | 33 +------- .../share/fabric/PersistentConnectIngress.kt | 15 ++++ .../share/fabric/PersistentDirectIngress.kt | 75 ++++++++++--------- .../connect/share/fabric/ui/ShareViewModel.kt | 66 +++++++++++++--- .../share/fabric/FriendPresenceMonitorTest.kt | 62 ++++++--------- .../fabric/PersistentConnectIngressTest.kt | 20 +++++ .../fabric/PersistentDirectIngressTest.kt | 24 ++++-- .../share/fabric/ui/ShareViewModelTest.kt | 64 +++++++++++++++- 18 files changed, 315 insertions(+), 171 deletions(-) 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 index 2ab42f875..cf23b2c50 100644 --- 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 @@ -77,7 +77,6 @@ class ConnectShare12111Client : ClientModInitializer { val statusProbe = MinecraftStatusProbe() val remotePresence = FriendPresenceMonitor( store = friendStore, - probe = statusProbe, directProbe = { friend -> browserReference.get()?.probeLan( friend = friend, @@ -85,8 +84,6 @@ class ConnectShare12111Client : ClientModInitializer { probe = statusProbe, ) }, - ownConnectAddress = - ConnectShareClient::connectPublicAddress, ) scope.launch { while (isActive) { 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 index 042dd91d8..6d69d3727 100644 --- 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 @@ -10,7 +10,7 @@ @Mixin(IntegratedServer.class) public abstract class IntegratedServerMixin { @Redirect( - method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;I)Z", + method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;Lnet/minecraft/world/level/GameType;ZI)Z", at = @At( value = "INVOKE", target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) 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 index c45b4a9aa..3de8ee6ce 100644 --- 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 @@ -77,7 +77,6 @@ class ConnectShare262Client : ClientModInitializer { val statusProbe = MinecraftStatusProbe() val remotePresence = FriendPresenceMonitor( store = friendStore, - probe = statusProbe, directProbe = { friend -> browserReference.get()?.probeLan( friend = friend, @@ -85,8 +84,6 @@ class ConnectShare262Client : ClientModInitializer { probe = statusProbe, ) }, - ownConnectAddress = - ConnectShareClient::connectPublicAddress, ) scope.launch { while (isActive) { 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 index 9660c135a..f9eabfc53 100644 --- 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 @@ -188,6 +188,28 @@ class Fabric262ArtifactTest { } } + @Test + fun `mixin redirects the four 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;" + + "Lnet/minecraft/world/level/GameType;ZI)Z" in bytecode, + ) + assertFalse( + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + bytecode, + ) + } + } + @Test fun `packaged loader reads libp2p only from child payload`() { URLClassLoader( 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 index a92886171..8bbc31119 100644 --- 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 @@ -53,4 +53,12 @@ class ConnectControlPlane( 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 index 677b672b9..30597c0b4 100644 --- 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 @@ -31,7 +31,7 @@ data class ConnectShareInstallation( val browser: FabricShareBrowser, val friendActivity: FriendActivityMonitor, val gateway: ShareConnectionGateway, - val ownConnectAddress: String, + val ownConnectAddress: () -> String, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -115,7 +115,7 @@ object ConnectShareClient { @JvmStatic fun connectPublicAddress(): String? = - installation?.ownConnectAddress + installation?.ownConnectAddress?.invoke() @JvmStatic fun armFriendCardExchange(peerId: String) { 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 index 5b547f486..278d256a5 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -17,13 +18,16 @@ internal class FabricDirectPeerRuntime private constructor( ) { constructor( dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( - node = CoreFabricDirectPeerNode( - DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + browser = FabricShareBrowser(dataDirectory), + ingress = FabricDirectShareIngress( + dataDirectory = dataDirectory, + displayName = displayName, + accessIdentityStore = accessIdentityStore, ), - dataDirectory = dataDirectory, - displayName = displayName, ) private constructor( @@ -49,9 +53,6 @@ internal class FabricDirectPeerRuntime private constructor( dataDirectory = dataDirectory, displayName = displayName, ) - - private const val IDENTITY_FILE_NAME = - "share-libp2p-identity.key" } } 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 index 591f47a41..7acf402ef 100644 --- 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 @@ -32,6 +32,8 @@ class FabricDirectShareIngress private constructor( ) : DirectShareIngress { constructor( dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( nodeFactory = { @@ -40,9 +42,7 @@ class FabricDirectShareIngress private constructor( ) }, now = Instant::now, - accessIdentity = ShareAccessIdentityStore( - dataDirectory, - )::currentOrCreate, + accessIdentity = accessIdentityStore::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, ) 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 index 155b5b1c7..01dd48408 100644 --- 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 @@ -16,6 +16,7 @@ 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.ShareAccessIdentityStore import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -108,12 +109,15 @@ object FabricShareBootstrap { timeout = 10.seconds, ) val endpointIdentity = identityStore.currentOrCreate() - val ownConnectAddress = - "${endpointIdentity.endpoint}.play.minekube.net" + val ownConnectAddress = AtomicReference( + "${endpointIdentity.endpoint}.play.minekube.net", + ) + val accessIdentityStore = ShareAccessIdentityStore(dataDirectory) val friendCardIssuer = FriendCardIssuer( dataDirectory = dataDirectory, displayName = playerDisplayName, - connectAddress = { ownConnectAddress }, + connectAddress = { ownConnectAddress.get() }, + accessIdentityStore = accessIdentityStore, ) val friendCardReceiver = FriendCardReceiver(friendStore) val friendRequestServer = FriendRequestServer( @@ -131,6 +135,7 @@ object FabricShareBootstrap { val directPeer = FabricDirectPeerRuntime( dataDirectory = dataDirectory, displayName = worldDisplayName, + accessIdentityStore = accessIdentityStore, ) val activeBrowser = directPeer.browser browser = activeBrowser @@ -169,6 +174,25 @@ object FabricShareBootstrap { directIngress = directIngress, failureReporter = logger::warn, ) + val controlPlane = ConnectControlPlane( + scope = scope, + ingress = ingress, + identity = identityStore::currentOrCreate, + target = gateway.serverSocketAddress, + failureReporter = logger::warn, + ).also(ConnectControlPlane::start) + val directControlPlane = DirectControlPlane( + scope = scope, + ingress = directIngress, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ), + target = gateway.directAddress, + connectAddress = { ownConnectAddress.get() }, + failureReporter = logger::warn, + ).also(DirectControlPlane::start) val viewModel = ShareViewModel( scope = scope, shareState = coordinator.state, @@ -185,6 +209,13 @@ object FabricShareBootstrap { store = identityStore, validator = validator, ), + onIdentityChanged = { + ownConnectAddress.set( + "${identityStore.currentOrCreate().endpoint}" + + ".play.minekube.net", + ) + controlPlane.restart() + }, startShare = coordinator::start, stopShare = coordinator::stop, answerAdmission = admission::answer, @@ -262,25 +293,6 @@ object FabricShareBootstrap { receiver = friendCardReceiver, requestClient = friendRequestClient, ) - val controlPlane = ConnectControlPlane( - scope = scope, - ingress = ingress, - identity = { endpointIdentity }, - target = gateway.serverSocketAddress, - failureReporter = logger::warn, - ).also(ConnectControlPlane::start) - val directControlPlane = DirectControlPlane( - scope = scope, - ingress = directIngress, - options = ShareOptions( - gameMode = ShareGameMode.SURVIVAL, - allowCheats = false, - allowInternetDirect = false, - ), - target = gateway.directAddress, - connectAddress = { ownConnectAddress }, - failureReporter = logger::warn, - ).also(DirectControlPlane::start) return ConnectShareInstallation( viewModel = viewModel, friendsViewModel = friendsViewModel, @@ -295,7 +307,7 @@ object FabricShareBootstrap { browser = activeBrowser, friendActivity = activityMonitor, gateway = gateway, - ownConnectAddress = ownConnectAddress, + ownConnectAddress = ownConnectAddress::get, screens = screens, guestScreens = guestScreens, ) 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 index 01ecf78d8..673ca597c 100644 --- 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 @@ -50,6 +50,8 @@ class FriendCardReceiver( class FriendCardIssuer( private val dataDirectory: Path, private val displayName: () -> String? = { null }, + private val accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), private val connectAddress: suspend () -> String?, ) { suspend fun issue( @@ -63,9 +65,7 @@ class FriendCardIssuer( FriendCardIssueFailure } Either.catch { - val access = ShareAccessIdentityStore( - dataDirectory, - ).currentOrCreate() + val access = accessIdentityStore.currentOrCreate() DirectP2pNode( dataDirectory.resolve(IDENTITY_FILE_NAME), ).use { node -> 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 index c3d7a6cba..ccc3ee8f3 100644 --- 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 @@ -41,22 +41,16 @@ class FriendOnlineTracker { class FriendPresenceMonitor private constructor( private val friends: () -> List, - private val probe: FriendStatusProbe, private val directProbe: suspend (SavedFriend) -> ServerPresence?, - private val ownConnectAddress: () -> String?, private val ioDispatcher: CoroutineDispatcher, ) { constructor( store: FriendStore, - probe: FriendStatusProbe = MinecraftStatusProbe(), directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, - ownConnectAddress: () -> String? = { null }, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : this( friends = store::all, - probe = probe, directProbe = directProbe, - ownConnectAddress = ownConnectAddress, ioDispatcher = ioDispatcher, ) @@ -70,7 +64,6 @@ class FriendPresenceMonitor private constructor( val saved = runCatching(friends) .getOrDefault(emptyList()) .take(MAX_PROBED_FRIENDS) - val ownAddress = runCatching(ownConnectAddress).getOrNull() val results = saved.parMap( context = ioDispatcher, concurrency = MAX_CONCURRENT_PROBES, @@ -82,30 +75,14 @@ class FriendPresenceMonitor private constructor( } catch (_: Exception) { null } - val connectPresence = if (directPresence == null) { - friend.connectAddress?.let { address -> - if (connectAddressesMatch(address, ownAddress)) { - null - } else { - probe.probe(address).getOrNull() - } - } - } else { - null - } - val presence = directPresence ?: connectPresence friend.peerId to RemoteFriendPresence( peerId = friend.peerId, displayName = friend.displayName, - online = presence != null, - description = presence?.description, + online = directPresence != null, + description = directPresence?.description, notifyWhenOnline = friend.permissions.notifyWhenOnline, - route = when { - directPresence != null -> ShareRoute.DIRECT_LAN - connectPresence != null -> ShareRoute.CONNECT - else -> null - }, + route = directPresence?.let { ShareRoute.DIRECT_LAN }, ) } mutableState.value = results.toMap() @@ -114,17 +91,13 @@ class FriendPresenceMonitor private constructor( companion object { internal fun testing( friends: () -> List, - probe: FriendStatusProbe, directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, - ownConnectAddress: () -> String? = { null }, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) = FriendPresenceMonitor( friends, - probe, directProbe, - ownConnectAddress, ioDispatcher, ) 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 index 9fae32759..e7c93740c 100644 --- 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 @@ -122,6 +122,21 @@ class PersistentConnectIngress( } } + 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, 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 index 875c01e00..0a9e4a3ab 100644 --- 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 @@ -72,38 +72,7 @@ class PersistentDirectIngress( .borrow(target, connectAddress) .right() - else -> { - mutableState.value = PersistentDirectState.Starting - 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() - } - } + else -> startFresh(options, target, connectAddress) } } @@ -111,11 +80,14 @@ class PersistentDirectIngress( options: ShareOptions, target: SocketAddress, connectAddress: String?, - ): DirectShareHandle = startControl( - options, - target, - connectAddress, - ).fold( + ): 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) }, @@ -155,4 +127,33 @@ class PersistentDirectIngress( 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/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt index 1bf9547da..52b58f751 100644 --- 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 @@ -16,6 +16,8 @@ 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 @@ -114,6 +116,8 @@ class ShareViewModel( 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 mutableState = MutableStateFlow( ShareUiState( @@ -141,7 +145,7 @@ class ShareViewModel( update { copy(pendingAdmissions = next) } } } - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { val identity = identityActions.current() update { @@ -187,7 +191,7 @@ class ShareViewModel( fun start() { if (!state.value.startEnabled) return - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { setShareWithFriendsEnabled(true) startCurrentWorld() @@ -196,7 +200,7 @@ class ShareViewModel( } fun stop() { - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { try { setShareWithFriendsEnabled(false) @@ -214,8 +218,10 @@ class ShareViewModel( ) { return } - runOperation { - startCurrentWorld() + kotlinx.coroutines.withContext(operationDispatcher) { + runOperation { + startCurrentWorld() + } } } @@ -228,6 +234,10 @@ class ShareViewModel( } fun setImportEndpoint(endpoint: String) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } update { if (!importDraft.endpointEditable) { this @@ -238,6 +248,10 @@ class ShareViewModel( } fun setImportToken(token: String) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } update { if (!importDraft.tokenEditable) { this @@ -248,12 +262,16 @@ class ShareViewModel( } fun importIdentity() { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } val draft = state.value.importDraft if (!draft.endpointEditable || !draft.tokenEditable) { update { copy(safeMessage = MANAGED_MESSAGE) } return } - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { applyIdentityResult( identityActions.import(draft.endpoint, draft.token), @@ -263,12 +281,16 @@ class ShareViewModel( } 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(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { applyIdentityResult( identityActions.importTokenFile(draft.endpoint, tokenFile), @@ -278,14 +300,18 @@ class ShareViewModel( } fun resetIdentity() { - scope.launch(start = CoroutineStart.UNDISPATCHED) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + scope.launch(context = operationDispatcher) { runOperation { applyIdentityResult(identityActions.reset()) } } } - private fun applyIdentityResult( + private suspend fun applyIdentityResult( result: Either, ) { result.fold( @@ -293,6 +319,7 @@ class ShareViewModel( update { copy(safeMessage = failure.safeMessage) } }, ifRight = { identity -> + onIdentityChanged() update { copy( identity = identity, @@ -349,6 +376,25 @@ class ShareViewModel( 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( @@ -361,6 +407,8 @@ class ShareViewModel( "Connect credentials are managed by the environment" const val GENERIC_FAILURE_MESSAGE = "Could not update Connect Share" + const val IDENTITY_ACTIVE_MESSAGE = + "Stop sharing before changing Connect credentials" } } 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 index 413773ff8..2bb9d8ae8 100644 --- 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 @@ -1,6 +1,5 @@ package com.minekube.connect.share.fabric -import arrow.core.Either import com.minekube.connect.share.direct.ShareRoute import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.share.friend.SavedFriend @@ -28,9 +27,6 @@ class FriendPresenceMonitorTest { loads++ emptyList() }, - probe = FriendStatusProbe { - error("no friends should be probed") - }, ioDispatcher = io, ) @@ -56,11 +52,11 @@ class FriendPresenceMonitorTest { ) val monitor = FriendPresenceMonitor.testing( friends = { listOf(online, offline) }, - probe = FriendStatusProbe { address -> - if (address.startsWith("online")) { - Either.Right(ServerPresence("Robin's World")) + directProbe = { friend -> + if (friend.peerId == online.peerId) { + ServerPresence("Robin's World") } else { - Either.Left(StatusProbeError.EndpointOffline) + null } }, ) @@ -78,21 +74,16 @@ class FriendPresenceMonitorTest { } @Test - fun `direct LAN status is preferred before Connect presence`() = runTest { + fun `direct LAN status is authenticated before being reported`() = runTest { val nearby = friend( peerId = "12D3KooWNearby", address = "nearby.play.minekube.net", ) - val connectProbes = mutableListOf() val monitor = FriendPresenceMonitor.testing( friends = { listOf(nearby) }, directProbe = { ServerPresence("Robin's LAN World") }, - probe = FriendStatusProbe { address -> - connectProbes += address - Either.Right(ServerPresence("Wrong Connect World")) - }, ) monitor.refresh() @@ -101,7 +92,22 @@ class FriendPresenceMonitorTest { assertTrue(presence.online) assertEquals(ShareRoute.DIRECT_LAN, presence.route) assertEquals("Robin's LAN World", presence.description) - assertTrue(connectProbes.isEmpty()) + } + + @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 @@ -118,9 +124,6 @@ class FriendPresenceMonitorTest { directProbe = { throw CancellationException("cancelled") }, - probe = FriendStatusProbe { - Either.Right(ServerPresence("must not run")) - }, ) assertFailsWith { @@ -161,29 +164,6 @@ class FriendPresenceMonitorTest { ) } - @Test - fun `refresh never probes this profiles own Connect endpoint as a friend`() = - runTest { - val copied = friend( - peerId = "12D3KooWCopiedEndpoint", - address = "mine.play.minekube.net", - ) - val probed = mutableListOf() - val monitor = FriendPresenceMonitor.testing( - friends = { listOf(copied) }, - ownConnectAddress = { "mine.play.minekube.net" }, - probe = FriendStatusProbe { address -> - probed += address - Either.Right(ServerPresence("Wrong self presence")) - }, - ) - - monitor.refresh() - - assertTrue(probed.isEmpty()) - assertFalse(monitor.state.value.getValue(copied.peerId).online) - } - private fun friend( peerId: String, address: String, 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 index f445610f0..8b489680b 100644 --- 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 @@ -93,6 +93,26 @@ class PersistentConnectIngressTest { 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 { 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 index 333f4bff8..8287a1251 100644 --- 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 @@ -19,7 +19,7 @@ import kotlinx.coroutines.runBlocking class PersistentDirectIngressTest { @Test - fun `title startup and world leases share one direct host until shutdown`() = + fun `world starts replace the title host and publish current invitations`() = runBlocking { val delegate = FakeIngress() val persistent = PersistentDirectIngress(delegate) @@ -46,21 +46,27 @@ class PersistentDirectIngressTest { CONNECT_ADDRESS, ) val secondWorld = persistent.start( - CONTROL_OPTIONS, + CONTROL_OPTIONS.copy(allowInternetDirect = true), TARGET, CONNECT_ADDRESS, ) firstWorld.close() secondWorld.close() - assertEquals(0, delegate.closes.get()) - assertEquals(INVITATION, firstWorld.invitation) + 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(1, delegate.closes.get()) + assertEquals(3, delegate.closes.get()) assertEquals(PersistentDirectState.Closed, persistent.state.value) } @@ -101,14 +107,14 @@ class PersistentDirectIngressTest { ).getOrNull()!! assertFailsWith { - persistent.start( + persistent.startControl( CONTROL_OPTIONS, InetSocketAddress(InetAddress.getLoopbackAddress(), 25_566), CONNECT_ADDRESS, ) } assertFailsWith { - persistent.start( + persistent.startControl( CONTROL_OPTIONS, TARGET, "other.play.minekube.net", @@ -123,6 +129,7 @@ class PersistentDirectIngressTest { ) : DirectShareIngress { val starts = AtomicInteger() val closes = AtomicInteger() + val startedOptions = mutableListOf() override suspend fun start( options: ShareOptions, @@ -130,11 +137,12 @@ class PersistentDirectIngressTest { connectAddress: String?, ): DirectShareHandle { val attempt = starts.incrementAndGet() + startedOptions += options if (attempt <= failuresBeforeSuccess) { error("simulated direct startup failure") } return DirectShareHandle( - invitation = INVITATION, + invitation = "$INVITATION-$attempt", lanAvailable = true, internetAvailable = false, close = { 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 index 39a747674..fad15fe49 100644 --- 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 @@ -11,8 +11,11 @@ import com.minekube.connect.share.identity.CredentialSource import com.minekube.connect.share.identity.CredentialValidationError 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 @@ -148,6 +151,61 @@ class ShareViewModelTest { 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 `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( + "Stop sharing before changing Connect credentials", + viewModel.state.value.safeMessage, + ) + } + @Test fun `enabled friend sharing resumes automatically in a new world`() = runTest { var starts = 0 @@ -179,6 +237,9 @@ class ShareViewModelTest { pending: MutableStateFlow> = MutableStateFlow(emptyList()), worldAvailable: Boolean = true, + scope: CoroutineScope = backgroundScope, + operationDispatcher: CoroutineDispatcher = + StandardTestDispatcher(testScheduler), identityActions: EndpointIdentityUiActions = FakeIdentityActions(localIdentity()), answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, @@ -195,12 +256,13 @@ class ShareViewModelTest { ) }, ) = ShareViewModel( - scope = backgroundScope, + scope = scope, shareState = shareState, pendingAdmissions = pending, initialWorldAvailable = worldAvailable, identityActions = identityActions, initialShareWithFriendsEnabled = initialShareWithFriends, + operationDispatcher = operationDispatcher, persistShareWithFriendsEnabled = persistShareWithFriends, startShare = startShare, stopShare = { Either.Right(Unit) }, From 07616b1cece8d9ffda364efff94ba903ee573ce2 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 00:14:34 +0200 Subject: [PATCH 044/188] no-mistakes(test): Fixed ShareViewModel scheduler setup; focused rerun passes --- .../com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fad15fe49..f25f82769 100644 --- 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 @@ -237,7 +237,7 @@ class ShareViewModelTest { pending: MutableStateFlow> = MutableStateFlow(emptyList()), worldAvailable: Boolean = true, - scope: CoroutineScope = backgroundScope, + scope: CoroutineScope = CoroutineScope(StandardTestDispatcher(testScheduler)), operationDispatcher: CoroutineDispatcher = StandardTestDispatcher(testScheduler), identityActions: EndpointIdentityUiActions = From 5512664de3e0a93116fba41ac2e766e9a6b6461d Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 00:40:35 +0200 Subject: [PATCH 045/188] no-mistakes(test): Fix Fabric 26.2 LAN redirect overload; focused tests pass --- .../share/fabric/v26_2/mixin/IntegratedServerMixin.java | 2 +- .../connect/share/fabric/v26_2/Fabric262ArtifactTest.kt | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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 index 6d69d3727..042dd91d8 100644 --- 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 @@ -10,7 +10,7 @@ @Mixin(IntegratedServer.class) public abstract class IntegratedServerMixin { @Redirect( - method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;Lnet/minecraft/world/level/GameType;ZI)Z", + method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;I)Z", at = @At( value = "INVOKE", target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) 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 index f9eabfc53..44ec7811a 100644 --- 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 @@ -189,7 +189,7 @@ class Fabric262ArtifactTest { } @Test - fun `mixin redirects the four argument 262 publish overload`() { + 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/" + @@ -200,11 +200,12 @@ class Fabric262ArtifactTest { it.readBytes().toString(Charsets.ISO_8859_1) } assertTrue( - "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;" + - "Lnet/minecraft/world/level/GameType;ZI)Z" in bytecode, + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + bytecode, ) assertFalse( - "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;" + + "Lnet/minecraft/world/level/GameType;ZI)Z" in bytecode, ) } From 767ee82b55b12736aabdb73c6cdd6da2b8b08003 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 01:12:02 +0200 Subject: [PATCH 046/188] no-mistakes(document): Refreshed Connect Share docs and cleared whitespace lint --- README.md | 8 ++- docs/connect-share-testing.md | 17 +++--- .../2026-07-30-connect-share-direct-p2p.md | 12 ++-- .../2026-07-30-connect-share-singleplayer.md | 15 ++--- .../2026-07-30-connect-share-mod-design.md | 59 ++++++++++--------- ...-connect-share-pasted-lan-invite-design.md | 4 +- 6 files changed, 62 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 86b426fbe..5ad019097 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,15 @@ the LAN or internet. The current implementation provides: -- a native **Share with Connect** flow in the pause menu; -- a native **Join Connect Share** flow on the title screen; +- a native **Share with friends** flow in the pause menu; +- a native **Friends** flow on the title screen, including **Join Connect Share**; - one persistent endpoint identity reused across worlds and restarts; +- one authenticated libp2p friend identity, with presence and world details + visible only to confirmed friends; - import of an existing dashboard endpoint and token, including `token.json`; - `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; - a stable `*.play.minekube.net` address for unmodified Java clients; -- signed, temporary invitations for modded clients; +- signed friend links and temporary world invitations for modded clients; - automatic same-LAN discovery and direct libp2p transport; - optional internet-direct attempts only when host and guest both opt in; - exactly-once fallback to Connect, which is the only relay; diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 26f2cfd9b..79af80454 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -22,7 +22,7 @@ Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. ## Identity reuse and import -1. Start a singleplayer world and choose **Share with Connect**. +1. Start a singleplayer world and choose **Share with friends**. 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. @@ -33,8 +33,7 @@ Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. 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, including any - hostname or custom-domain configuration attached to it. +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. @@ -63,9 +62,9 @@ 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 with Connect**, and leave - **Allow direct internet connections** disabled. -2. On the guest title screen, choose **Join Connect Share**. +1. Start a host world, choose **Share with friends**, 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 @@ -75,9 +74,9 @@ route works. 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. -8. Start sharing again. Confirm the libp2p peer identity, share capability, and - invitation changed while the persistent Connect endpoint did not. + 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 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 index c3fc94ef6..985db3445 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -22,8 +22,10 @@ classloader boundary. - 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. -- Peer identities, capabilities, invitations, and approvals are ephemeral per - share. The Connect endpoint token remains the only persistent network secret. +- 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 @@ -38,7 +40,7 @@ classloader boundary. ## 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, ephemeral identities, + 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. @@ -62,9 +64,9 @@ classloader boundary. ## Task 4: Guest discovery, invitation join, and fallback - Add a shared browser/join service with bounded LAN and internet timeouts. -- Start discovery when the multiplayer/Join Share UI is open and remove it on +- Start discovery when the **Friends**/**Join Connect Share** UI is open and remove it on close. -- Add native Minecraft Join Share UI to both Fabric versions, including paste +- 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 diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 43acd590e..5f7ef4cfe 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -1158,27 +1158,28 @@ Listen for client disconnect/game shutdown/integrated-server replacement and cal - [ ] **Step 3: Implement exact screens** -The pause menu button is **Share with Connect** when idle and **Connect Share** when active. +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, and **Start Sharing**. +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**; -- **Endpoint identity** link. +- **Allow**, **Deny**, and **Stop sharing with friends**; +- **Advanced settings…** link. The identity screen contains: - endpoint name; - masked credential source; -- **Import existing endpoint**; +- **Import token.json…**; - endpoint field plus masked token field; - `token.json` chooser; - **Validate and save**; -- warned **Reset Connect identity**. +- warned **Reset endpoint identity…**. Never render or retain a successful token value. @@ -1313,7 +1314,7 @@ Archive each remapped mod JAR under a distinct artifact name. Do not add mod fil 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 hostname/custom-domain configuration. +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. 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 index 8799ee3f1..2c0491152 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -1,6 +1,6 @@ # Connect Share Mod Design -**Date:** 2026-07-30 +**Date:** 2026-07-30 **Status:** Approved for implementation **Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) @@ -23,7 +23,7 @@ logic is written in Kotlin and shared across both versions. ## Product Decisions -- The host starts sharing from a dedicated **Share with Connect** pause-menu +- 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 @@ -66,8 +66,8 @@ logic is written in Kotlin and shared across both versions. 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, public - hostname, and attached custom domains without creating a duplicate endpoint. +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. @@ -187,7 +187,7 @@ config/minekube-connect-share/config.json config/minekube-connect-share/token.json ``` -`config.json` stores the endpoint name and non-secret user settings. +`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 @@ -209,7 +209,7 @@ 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 Connect identity**. Resetting warns that it creates +explicitly choose **Reset endpoint identity…**. Resetting warns that it creates a new endpoint and invalidates the old local identity. The identity setup screen offers: @@ -250,8 +250,10 @@ 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. -Every share creates an ephemeral libp2p identity so separate shares cannot be -correlated by a stable peer ID. The direct service supports: +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; @@ -285,9 +287,9 @@ The signed payload contains: - share ID; - expiry; - persistent Connect hostname when Connect is available; -- ephemeral host peer ID; +- installation-scoped host peer ID; - direct candidates only when the host enabled internet P2P; -- an unguessable per-share capability; +- an unguessable persisted access capability; - the host peer signature over every preceding field. The capability authorizes requesting admission; it never bypasses host @@ -296,7 +298,7 @@ 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 Share screen. Pasting the URI into Minecraft's +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. @@ -345,14 +347,14 @@ indicator; it does not spam chat. ### Host -The pause menu contains **Share with Connect**. The setup screen shows: +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 direct internet connections**, off by default, with an IP-disclosure +- **Allow faster direct internet connections**, off by default, with an IP-disclosure warning; -- **Start Sharing**. +- **Share with friends**. While active, the screen shows: @@ -361,13 +363,14 @@ While active, the screen shows: - Connect, LAN direct, and internet direct status separately; - connected and approved players; - pending approval cards; -- **Stop Sharing**. +- **Stop sharing with friends**. Connect identity settings show the endpoint name, credential source (generated, imported, or environment), and a masked token status. They provide -**Import existing endpoint** and the separately warned **Reset Connect -identity** action. The token value is never displayed again after a successful -import. +**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. @@ -375,7 +378,8 @@ the screen does not stop sharing. ### Guest Vanilla guests add or directly connect to the host's Connect hostname. Modded -guests can use **Join Share** or paste a `minekube://share/` invitation. The +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. @@ -398,8 +402,9 @@ authenticated. 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. -- Approvals, share capabilities, and ephemeral peer identities die with the - share. The Connect endpoint name and token persist across shares. +- 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. @@ -408,7 +413,7 @@ authenticated. - 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 - ephemeral direct peer. + active direct peer. - Malformed, expired, unsupported-version, incorrectly signed, or capability-mismatched invitations are rejected before dialing. @@ -497,11 +502,11 @@ Before calling the feature complete: 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 and token are - reused while the old signed invitation is rejected. -12. Import a dashboard-created endpoint and token, then prove its hostname and - attached dashboard configuration are used without creating another - endpoint. +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. 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 index c23989fd9..8a393008a 100644 --- 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 @@ -1,7 +1,7 @@ # Connect Share Pasted LAN Invitation Design -**Date:** 2026-07-30 -**Status:** Approved for implementation +**Date:** 2026-07-30 +**Status:** Approved for implementation **Parent design:** `2026-07-30-connect-share-mod-design.md` ## Problem From 501f436424de84e20e26afd0b1225fdc4884752f Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 01:39:05 +0200 Subject: [PATCH 047/188] no-mistakes: apply CI fixes --- .github/workflows/pullrequest.yml | 13 +++++-------- .github/workflows/release.yml | 2 +- build.gradle.kts | 2 +- settings.gradle.kts | 18 ++++++++++++------ 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index cb47ea70a..8bd02be56 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 543757b26..00e0c1d2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build - run: ./gradlew build + run: ./gradlew -Pskip-share=true build - name: Get version id: version diff --git a/build.gradle.kts b/build.gradle.kts index 9596b2df2..7ad94f382 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ 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 } diff --git a/settings.gradle.kts b/settings.gradle.kts index 04dfa74cc..87ef73136 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -96,9 +96,15 @@ include(":core") include(":bungee") include(":spigot") include(":velocity") -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-26-2") -project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") + +// 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-26-2") + project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") +} From 001efe7c0f95072aff9a0555b5cee41a7fc20325 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 1 Aug 2026 21:07:14 +0200 Subject: [PATCH 048/188] feat(share): complete universal party experience --- .github/workflows/connect-share-release.yml | 195 +++ .github/workflows/pullrequest.yml | 55 + README.md | 18 +- build-logic/src/main/kotlin/Versions.kt | 2 + .../tunnel/p2p/DirectP2pNodeRuntime.java | 24 +- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 44 + docs/connect-share-testing.md | 48 +- docs/connect-share.md | 86 ++ settings.gradle.kts | 13 + share/AGENTS.md | 14 + share/common/build.gradle.kts | 5 + .../share/friend/CompatibilityProfile.kt | 177 +++ .../connect/share/friend/FriendControlWire.kt | 97 +- .../connect/share/friend/FriendStore.kt | 157 ++- .../share/friend/SharePreferencesStore.kt | 39 +- .../share/friend/CompatibilityProfileTest.kt | 88 ++ .../share/friend/FriendControlWireTest.kt | 13 + .../connect/share/friend/FriendStoreTest.kt | 45 + .../share/friend/SharePreferencesStoreTest.kt | 18 + share/fabric-1.20.1/build.gradle.kts | 162 +++ .../v1_20_1/MinecraftGameProfileFactory.java | 18 + .../v1_20_1/mixin/ConnectionAccessor.java | 12 + .../mixin/IntegratedServerAccessor.java | 19 + .../v1_20_1/mixin/IntegratedServerMixin.java | 24 + .../mixin/LanServerPingerAccessor.java | 12 + .../v1_20_1/mixin/PauseScreenMixin.java | 55 + .../ServerConnectionListenerAccessor.java | 13 + .../mixin/ServerConnectionListenerMixin.java | 57 + .../mixin/ServerLoginPacketListenerMixin.java | 99 ++ .../v1_20_1/mixin/TitleScreenMixin.java | 30 + .../fabric/v1_20_1/BlockedFriendsScreen.kt | 70 + .../v1_20_1/CompatibilityMismatchScreen.kt | 109 ++ .../v1_20_1/ConnectGameProfileMapper.kt | 50 + .../fabric/v1_20_1/ConnectShare12111Client.kt | 477 +++++++ .../fabric/v1_20_1/EndpointIdentityScreen.kt | 171 +++ .../v1_20_1/FabricConnectShare1201Client.kt | 68 + .../fabric/v1_20_1/FriendCardNetworking.kt | 84 ++ .../share/fabric/v1_20_1/FriendCardPayload.kt | 37 + .../fabric/v1_20_1/Minecraft12111Bridge.kt | 62 + .../v1_20_1/Minecraft12111LoginBridge.kt | 178 +++ .../fabric/v1_20_1/ObservableCheckbox.kt | 19 + .../share/fabric/v1_20_1/ShareJoinScreen.kt | 1192 +++++++++++++++++ .../fabric/v1_20_1/SharePrivacyScreen.kt | 108 ++ .../share/fabric/v1_20_1/ShareSetupScreen.kt | 151 +++ .../share/fabric/v1_20_1/ShareStatusScreen.kt | 210 +++ .../v1_20_1/VanillaMinecraft12111Transport.kt | 149 +++ .../assets/connect-share/lang/de_de.json | 149 +++ .../assets/connect-share/lang/en_us.json | 149 +++ .../connect-share-fabric-1.20.1.mixins.json | 22 + .../src/main/resources/fabric.mod.json | 26 + .../v1_20_1/CapturedServerTransportTest.kt | 58 + .../v1_20_1/ConnectGameProfileMapperTest.kt | 47 + .../fabric/v1_20_1/Fabric12111ArtifactTest.kt | 320 +++++ .../fabric/v1_20_1/FriendCardPayloadTest.kt | 41 + .../v1_20_1/Minecraft12111BridgeTest.kt | 125 ++ share/fabric-1.21.1/build.gradle.kts | 160 +++ .../v1_21_1/MinecraftGameProfileFactory.java | 23 + .../v1_21_1/mixin/ConnectionAccessor.java | 12 + .../mixin/IntegratedServerAccessor.java | 19 + .../v1_21_1/mixin/IntegratedServerMixin.java | 24 + .../mixin/LanServerPingerAccessor.java | 12 + .../v1_21_1/mixin/PauseScreenMixin.java | 55 + .../ServerConnectionListenerAccessor.java | 13 + .../mixin/ServerConnectionListenerMixin.java | 57 + .../mixin/ServerLoginPacketListenerMixin.java | 101 ++ .../v1_21_1/mixin/TitleScreenMixin.java | 30 + .../fabric/v1_21_1/BlockedFriendsScreen.kt | 70 + .../v1_21_1/CompatibilityMismatchScreen.kt | 109 ++ .../v1_21_1/ConnectGameProfileMapper.kt | 48 + .../fabric/v1_21_1/ConnectShare12111Client.kt | 482 +++++++ .../fabric/v1_21_1/EndpointIdentityScreen.kt | 171 +++ .../v1_21_1/FabricConnectShare1211Client.kt | 68 + .../fabric/v1_21_1/FriendCardNetworking.kt | 96 ++ .../share/fabric/v1_21_1/FriendCardPayload.kt | 55 + .../fabric/v1_21_1/Minecraft12111Bridge.kt | 62 + .../v1_21_1/Minecraft12111LoginBridge.kt | 179 +++ .../share/fabric/v1_21_1/ShareJoinScreen.kt | 1187 ++++++++++++++++ .../fabric/v1_21_1/SharePrivacyScreen.kt | 107 ++ .../share/fabric/v1_21_1/ShareSetupScreen.kt | 149 +++ .../share/fabric/v1_21_1/ShareStatusScreen.kt | 210 +++ .../v1_21_1/VanillaMinecraft12111Transport.kt | 149 +++ .../assets/connect-share/lang/de_de.json | 149 +++ .../assets/connect-share/lang/en_us.json | 149 +++ .../connect-share-fabric-1.21.1.mixins.json | 22 + .../src/main/resources/fabric.mod.json | 26 + .../v1_21_1/CapturedServerTransportTest.kt | 58 + .../v1_21_1/ConnectGameProfileMapperTest.kt | 47 + .../fabric/v1_21_1/Fabric12111ArtifactTest.kt | 288 ++++ .../fabric/v1_21_1/FriendCardPayloadTest.kt | 41 + .../v1_21_1/Minecraft12111BridgeTest.kt | 125 ++ share/fabric-1.21.11/build.gradle.kts | 14 + .../fabric/v1_21_11/BlockedFriendsScreen.kt | 70 + .../v1_21_11/CompatibilityMismatchScreen.kt | 109 ++ .../v1_21_11/ConnectShare12111Client.kt | 178 +++ .../share/fabric/v1_21_11/ShareJoinScreen.kt | 434 +++--- .../fabric/v1_21_11/SharePrivacyScreen.kt | 107 ++ .../share/fabric/v1_21_11/ShareSetupScreen.kt | 7 + .../fabric/v1_21_11/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 36 +- .../assets/connect-share/lang/en_us.json | 36 +- share/fabric-26.2/build.gradle.kts | 14 + .../fabric/v26_2/BlockedFriendsScreen.kt | 70 + .../v26_2/CompatibilityMismatchScreen.kt | 109 ++ .../fabric/v26_2/ConnectShare262Client.kt | 178 +++ .../share/fabric/v26_2/ShareJoinScreen.kt | 434 +++--- .../share/fabric/v26_2/SharePrivacyScreen.kt | 107 ++ .../share/fabric/v26_2/ShareSetupScreen.kt | 7 + .../share/fabric/v26_2/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 36 +- .../assets/connect-share/lang/en_us.json | 36 +- share/fabric-common/build.gradle.kts | 5 + .../share/fabric/ConnectShareClient.kt | 13 + .../share/fabric/FabricDirectPeerRuntime.kt | 15 +- .../share/fabric/FabricDirectShareIngress.kt | 27 +- .../share/fabric/FabricShareBootstrap.kt | 33 +- .../fabric/FollowNextSessionController.kt | 137 ++ .../share/fabric/FriendActivityResolver.kt | 12 +- .../share/fabric/FriendJoinOrchestrator.kt | 164 +++ .../share/fabric/FriendRequestServer.kt | 33 +- .../LoadedCompatibilityProfileFactory.kt | 91 ++ .../share/fabric/ShareJoinDiagnostics.kt | 65 + .../share/fabric/ui/FriendsViewModel.kt | 89 +- .../connect/share/fabric/ui/ListPage.kt | 35 + .../connect/share/fabric/ui/ShareViewModel.kt | 72 +- .../fabric/FabricDirectPeerRuntimeTest.kt | 49 +- .../fabric/FollowNextSessionControllerTest.kt | 128 ++ .../fabric/FriendJoinOrchestratorTest.kt | 134 ++ .../share/fabric/FriendRequestServerTest.kt | 104 ++ .../LoadedCompatibilityProfileFactoryTest.kt | 49 + .../share/fabric/PrismFriendJoinE2ETest.kt | 24 +- .../share/fabric/ShareJoinDiagnosticsTest.kt | 33 + .../share/fabric/ui/FriendsViewModelTest.kt | 63 + .../connect/share/fabric/ui/ListPageTest.kt | 41 + .../share/fabric/ui/ShareViewModelTest.kt | 49 + share/forge-1.20.1/build.gradle.kts | 219 +++ .../v1_20_1/ForgeConnectShare1201Client.kt | 78 ++ .../src/main/resources/META-INF/mods.toml | 36 + .../connect-share-forge-1.20.1.mixins.json | 23 + .../src/main/resources/pack.mcmeta | 6 + .../forge/v1_20_1/Forge1201ArtifactTest.kt | 55 + share/neoforge-1.21.1/build.gradle.kts | 181 +++ .../v1_21_1/NeoForgeConnectShare1211Client.kt | 76 ++ .../resources/META-INF/neoforge.mods.toml | 38 + .../src/main/resources/pack.mcmeta | 6 + .../v1_21_1/NeoForge1211ArtifactTest.kt | 42 + 145 files changed, 14052 insertions(+), 433 deletions(-) create mode 100644 .github/workflows/connect-share-release.yml create mode 100644 docs/connect-share.md create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/CompatibilityProfile.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/CompatibilityProfileTest.kt create mode 100644 share/fabric-1.20.1/build.gradle.kts create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/MinecraftGameProfileFactory.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ConnectionAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/PauseScreenMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapper.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FabricConnectShare1201Client.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111Bridge.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ObservableCheckbox.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt create mode 100644 share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-1.20.1/src/main/resources/connect-share-fabric-1.20.1.mixins.json create mode 100644 share/fabric-1.20.1/src/main/resources/fabric.mod.json create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/CapturedServerTransportTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Fabric12111ArtifactTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt create mode 100644 share/fabric-1.21.1/build.gradle.kts create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/MinecraftGameProfileFactory.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ConnectionAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/PauseScreenMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapper.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FabricConnectShare1211Client.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111Bridge.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt create mode 100644 share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-1.21.1/src/main/resources/connect-share-fabric-1.21.1.mixins.json create mode 100644 share/fabric-1.21.1/src/main/resources/fabric.mod.json create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/CapturedServerTransportTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Fabric12111ArtifactTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FollowNextSessionController.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestrator.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactory.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnostics.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ListPage.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestratorTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnosticsTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ListPageTest.kt create mode 100644 share/forge-1.20.1/build.gradle.kts create mode 100644 share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt create mode 100644 share/forge-1.20.1/src/main/resources/META-INF/mods.toml create mode 100644 share/forge-1.20.1/src/main/resources/connect-share-forge-1.20.1.mixins.json create mode 100644 share/forge-1.20.1/src/main/resources/pack.mcmeta create mode 100644 share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt create mode 100644 share/neoforge-1.21.1/build.gradle.kts create mode 100644 share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt create mode 100644 share/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml create mode 100644 share/neoforge-1.21.1/src/main/resources/pack.mcmeta create mode 100644 share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml new file mode 100644 index 000000000..287460f94 --- /dev/null +++ b/.github/workflows/connect-share-release.yml @@ -0,0 +1,195 @@ +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 + +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 }} + MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} + + 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: 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: Verify marketplace configuration + 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: Publish verified artifacts to Modrinth + 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 + 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 + done diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 8bd02be56..f5e0d55d0 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -57,6 +57,61 @@ jobs: 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 + loader: Fabric + artifact: connect-share-fabric-1.20.1-*.jar + - minecraft: 1.21.1 + project: fabric-1-21-1 + loader: Fabric + artifact: connect-share-fabric-1.21.1-*.jar + - minecraft: 1.20.1 + project: forge-1.20.1 + loader: Forge + artifact: connect-share-forge-1.20.1-*.jar + - minecraft: 1.21.1 + project: 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.project }}/build/libs/${{ matrix.artifact }} + !share/${{ matrix.project }}/build/libs/*-sources.jar + !share/${{ matrix.project }}/build/libs/*-dev-*.jar + !share/${{ matrix.project }}/build/libs/*-dev-shadow.jar + !share/${{ matrix.project }}/build/libs/*-unshaded.jar + !share/${{ matrix.project }}/build/libs/*-parent-shadow.jar + share-1-21-11: name: Connect Share / Minecraft 1.21.11 runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5ad019097..07695369a 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ low latency edge proxies network nearest to you. Please refer to https://connect.minekube.com for more documentation. -## Connect Share Fabric mod +## Connect Share mod -Connect Share is an in-development client-side Fabric mod for Minecraft Java -1.21.11 and 26.2. It shares a singleplayer world through Minekube Connect or +Connect Share is an in-development client-side Fabric, Forge, and NeoForge mod. +It supports Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and +NeoForge 1.21.1. It shares a singleplayer world through Minekube Connect or directly between two modded clients without exposing Minecraft's listener to the LAN or internet. @@ -36,7 +37,16 @@ The current implementation provides: - exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; - explicit support for authenticated and unverified offline-mode guests; and -- isolated, self-contained Fabric artifacts for both supported game versions. +- compatibility checks before a friend requests access; +- follow-next-session intents that never interrupt active gameplay; and +- isolated, version-and-loader-labelled artifacts for every supported target. + +Fabric builds require Fabric API and Fabric Language Kotlin. Forge and NeoForge +builds require the installable Kotlin for Forge `-all.jar`. Marketplace release +metadata declares the matching dependencies so compatible launchers, including +Prism, can install them automatically. Connect Share is MIT licensed and may be +included in modpacks without asking for additional permission. See +[the player, privacy, and distribution guide](docs/connect-share.md). The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See diff --git a/build-logic/src/main/kotlin/Versions.kt b/build-logic/src/main/kotlin/Versions.kt index 75bbcb13f..9c7e5357e 100644 --- a/build-logic/src/main/kotlin/Versions.kt +++ b/build-logic/src/main/kotlin/Versions.kt @@ -45,6 +45,8 @@ object Versions { 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" 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 index cbf0356f3..200a2d1c1 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -138,7 +138,11 @@ synchronized DirectP2pHostInfo startHost( DirectP2pHostHandler handler) { ensureOpen(); if (hostConfig != null) { - throw new IllegalStateException("Connect Share direct host is already started"); + 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"); @@ -183,9 +187,6 @@ synchronized void publish(String invitation) { if (hostConfig == null || host == null) { throw new IllegalStateException("Connect Share direct host is not started"); } - if (this.invitation != null) { - throw new IllegalStateException("Connect Share invitation is already published"); - } this.invitation = requireInvitation(invitation); startMdns(); } @@ -325,7 +326,13 @@ private synchronized void startMdns() { return; } InetAddress address = MdnsAddressSelector.systemAddress(); - JmDNS started = JmDNS.create(address); + // 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 @@ -334,7 +341,6 @@ private synchronized void startMdns() { List ipv6Addresses = address instanceof Inet6Address ? Collections.singletonList((Inet6Address) address) : Collections.emptyList(); - String peerId = host.getPeerId().toBase58(); started.registerService(ServiceInfo.create( MDNS_SERVICE, peerId, @@ -355,6 +361,12 @@ private synchronized void startMdns() { } } + 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) { 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 index a1722a124..a389b3ad8 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -259,6 +259,37 @@ void discoveryNodeCanBecomeThePublishedHostWithoutChangingItsPeer() { 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 = @@ -273,6 +304,19 @@ void mdnsTxtLengthPrefixSupportsModernEd25519PeerIds() { 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 directNodeNeverAdvertisesOrAcceptsCircuitRelayAddresses() { host = new DirectP2pNode(); diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 79af80454..12b65c5e3 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,7 +1,9 @@ # Connect Share acceptance -Connect Share is built separately for Minecraft Java 1.21.11 on Java 21 and -Minecraft Java 26.2 on Java 25. Run this pass against both artifacts before +Connect Share is built separately for Fabric 1.20.1, 1.21.1, and 1.21.11, +Forge 1.20.1, and NeoForge 1.21.1 on a Java 21 build 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 @@ -12,13 +14,23 @@ image, or roll anything out to production. From the repository root: ```sh -./gradlew :share:fabric-1-21-11:build -./gradlew :share:fabric-26-2:build +./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 matching Fabric Loader, Fabric API, and Fabric Language Kotlin. +Marketplace installs must resolve the latter two automatically. + +For Forge or NeoForge, install the matching loader and Kotlin for Forge. A +manual install must use Kotlin for Forge's `-all.jar`; its plain Maven artifact +is only a compile/library artifact and is not recognized as the loader mod. +Marketplace installs must resolve Kotlin for Forge automatically. ## Identity reuse and import @@ -128,14 +140,16 @@ self-hosted libp2p relay. Inspect the final JARs: ```sh -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 +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: +Each final artifact must contain its loader metadata, version-specific mixin +configuration, `pack.mcmeta` where the loader expects one, and: -- `fabric.mod.json`; -- the version-specific Connect Share mixin JSON; - English and German translations; - `LICENSE`; - `com/minekube/connect/share/` classes; and @@ -146,6 +160,22 @@ 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 for each of the six packaged +artifacts. 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, status, approval, and a new ` joined the game` host-log +line. Startup or control-plane reachability alone does not pass. + ## Evidence to retain Record the host and guest Minecraft versions, Java versions, artifact SHA-256 diff --git a/docs/connect-share.md b/docs/connect-share.md new file mode 100644 index 000000000..46db3a199 --- /dev/null +++ b/docs/connect-share.md @@ -0,0 +1,86 @@ +# 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 to + join**. 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. + +**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, current server/world name, and joinable state can each be + hidden independently under **Privacy**. +- Each friend can be set to **Ask Every Time**, **Auto-Accept**, or **Never + Allow**. The default is Ask 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 is opt-in on both sides because it can reveal public IP + addresses to that friend. Direct LAN addresses, endpoint tokens, invitation + capabilities, and private keys are never shown 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. + +## 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. + +Forge and NeoForge reuse the loader-neutral Kotlin core and version-specific +Minecraft UI/bridge adapters. Their packaged artifacts pass the same real +two-client Prism host/join gate as the Fabric artifacts. diff --git a/settings.gradle.kts b/settings.gradle.kts index 87ef73136..ee34a5e0e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -72,6 +72,9 @@ pluginManagement { maven("https://maven.fabricmc.net/") { name = "Fabric" } + maven("https://maven.neoforged.net/releases") { + name = "NeoForged" + } gradlePluginPortal() } repositories { @@ -84,6 +87,8 @@ pluginManagement { 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") @@ -105,6 +110,14 @@ if (!gradle.startParameter.projectProperties.containsKey("skip-share")) { 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 index 9e1d69952..9c190ad75 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -107,3 +107,17 @@ redesigned for Kotlin. supply `LIVE_DATA`, `LIVE_PORT_FILE`, and `LIVE_HOST_LOG`, then launch the guest against the port written to `LIVE_PORT_FILE`. The test succeeds only after the host logs a new ` joined the game` line. +- Invoke the live harness with `--rerun-tasks`. Its environment variables are + intentionally not task inputs, so an up-to-date result is not live evidence. +- Keep only one host and one guest identity active during a live run. Cloning a + Prism instance copies `share-libp2p-identity.key`; simultaneously advertising + that same peer identity from several processes makes mDNS routing ambiguous + and can produce misleading libp2p stream failures. +- Manually constructed Prism Forge/NeoForge components need correct + `cachedRequires` metadata and usually one online first launch to download + loader libraries. Kotlin for Forge must be installed from its `-all.jar`; + the smaller Maven compile artifact is not a discoverable loader mod. +- Legacy Forge's final reobfuscated JAR must contain its generated Mixin refmap + and name it from the loader-specific mixin config. Forge and NeoForge client + resources need a compatible `pack.mcmeta`, otherwise startup can stop at a + resource-pack warning before quick-play E2E begins. diff --git a/share/common/build.gradle.kts b/share/common/build.gradle.kts index 3527d1327..d2ee1ae00 100644 --- a/share/common/build.gradle.kts +++ b/share/common/build.gradle.kts @@ -1,9 +1,13 @@ +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) } @@ -11,6 +15,7 @@ java { kotlin { jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } dependencies { 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/FriendControlWire.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt index e8d2a1f3b..ecc5229e4 100644 --- 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 @@ -32,6 +32,9 @@ enum class FriendActivityKind { 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 { @@ -89,8 +92,13 @@ object FriendControlWire { 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 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, @@ -302,7 +310,14 @@ object FriendControlWire { 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) @@ -311,7 +326,11 @@ object FriendControlWire { FriendControlResponse.SharedWorldJoinAccepted -> write(8) } } - return output.toByteArray() + return output.toByteArray().also { + require(it.size <= MAX_REQUEST_BYTES) { + "Friend response is too large" + } + } } fun decodeResponse( @@ -336,8 +355,17 @@ object FriendControlWire { 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() + }, ), ) } @@ -378,6 +406,40 @@ object FriendControlWire { write(encoded) } + private fun ByteArrayOutputStream.writeCompatibilityProfile( + profile: CompatibilityProfile, + ) { + require(profile.requiredMods.size <= MAX_REQUIRED_MODS) { + "Compatibility profile has too many required mods" + } + 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()) } @@ -470,6 +532,39 @@ object FriendControlWire { return bytes[position++].toInt() and 0xff } + fun readCompatibilityProfile(): CompatibilityProfile { + 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() + } + return CompatibilityProfile( + minecraftVersion = minecraftVersion, + loader = loader, + requiredMods = mods, + pack = pack, + ) + } + fun ensure(condition: Boolean) { if (!condition) { invalid() 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 index 0f93d8cf8..72b66deb9 100644 --- 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 @@ -28,11 +28,34 @@ 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 canJoinAutomatically: Boolean = false, -) + 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, @@ -65,6 +88,17 @@ data class PendingFriendRemoval( 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 @@ -86,6 +120,11 @@ sealed interface FriendStoreError { 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( @@ -114,6 +153,13 @@ class FriendStore( 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, @@ -181,6 +227,9 @@ class FriendStore( 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 } @@ -205,7 +254,9 @@ class FriendStore( permissions = (existing?.permissions ?: FriendPermissions()) .let { permissions -> if (allowAutomaticJoin) { - permissions.copy(canJoinAutomatically = true) + permissions.copy( + accessPolicy = FriendAccessPolicy.AUTO_ACCEPT, + ) } else { permissions } @@ -273,6 +324,48 @@ class FriendStore( 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): Boolean { val current = read() @@ -349,7 +442,17 @@ class FriendStore( if (removals.size > MAX_FRIENDS) { throw IOException("Friends file contains too many removals") } - return StoreData(friends, 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) { @@ -372,6 +475,19 @@ class FriendStore( ), ) + 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): SavedFriend { val peerId = json.requiredString("peerId") val publicKey = json.requiredString("publicKey") @@ -397,8 +513,13 @@ class FriendStore( permissions.requiredBoolean("notifyWhenOnline"), canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), - canJoinAutomatically = - permissions.requiredBoolean("canJoinAutomatically"), + accessPolicy = permissions.optionalString("accessPolicy") + ?.let(FriendAccessPolicy::valueOf) + ?: if (permissions.requiredBoolean("canJoinAutomatically")) { + FriendAccessPolicy.AUTO_ACCEPT + } else { + FriendAccessPolicy.ASK_EVERY_TIME + }, ) val relationshipStatus = json .optionalString("relationshipStatus") @@ -431,6 +552,9 @@ class FriendStore( 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 -> @@ -451,6 +575,19 @@ class FriendStore( 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( @@ -473,10 +610,7 @@ class FriendStore( JsonObject().apply { addProperty("notifyWhenOnline", permissions.notifyWhenOnline) addProperty("canSeeMyWorlds", permissions.canSeeMyWorlds) - addProperty( - "canJoinAutomatically", - permissions.canJoinAutomatically, - ) + addProperty("accessPolicy", permissions.accessPolicy.name) }, ) } @@ -539,7 +673,7 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" private const val MIN_WIRE_VERSION = 1 - private const val WIRE_VERSION = 2 + private const val WIRE_VERSION = 4 private const val MAX_FRIENDS = 256 private const val MAX_DISPLAY_NAME_LENGTH = 64 private val GSON = Gson() @@ -574,5 +708,6 @@ class FriendStore( 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/SharePreferencesStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt index a84b315ae..79144b29f 100644 --- 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 @@ -15,8 +15,16 @@ 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( @@ -33,11 +41,28 @@ class SharePreferencesStore( Files.readString(preferencesFile), JsonObject::class.java, ) ?: throw IOException("Share preferences are empty") - if (json.requiredInt("version") != WIRE_VERSION) { + 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) @@ -52,6 +77,15 @@ class SharePreferencesStore( 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, @@ -95,7 +129,8 @@ class SharePreferencesStore( companion object { const val FILE_NAME = "share-preferences.json" - private const val WIRE_VERSION = 1 + private const val MIN_WIRE_VERSION = 1 + private const val WIRE_VERSION = 2 private val GSON = Gson() } } 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/FriendControlWireTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt index c210904ac..ea1190c1f 100644 --- 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 @@ -42,6 +42,19 @@ class FriendControlWireTest { 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"), 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 index 39ddc437e..91d69bba2 100644 --- 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 @@ -190,6 +190,51 @@ class FriendStoreTest { 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 `approved friend can be bound to an authenticated Minecraft identity`() { val store = FriendStore(tempDir) 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 index c9f73ce5e..017b83891 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -22,4 +23,21 @@ class SharePreferencesStoreTest { 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/fabric-1.20.1/build.gradle.kts b/share/fabric-1.20.1/build.gradle.kts new file mode 100644 index 000000000..1ff9e8802 --- /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 = 90L * 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 90 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..c1799c89d --- /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,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..d2e84b243 --- /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,99 @@ +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; + 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; + 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..34bc29bed --- /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,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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..2ff433f56 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } +} 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..06a07a2f3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft!!.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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..8daf811e0 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt @@ -0,0 +1,477 @@ +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.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 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()?.probeLan( + 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, + -> + 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + + 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 + + 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..09b96e09e --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.setFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..3f2aea4ff --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt @@ -0,0 +1,84 @@ +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 invitation = runCatching { + buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS) + }.getOrNull() ?: return@registerGlobalReceiver + server.execute { + val proof = approvedJoins.consume( + player.gameProfile.name, + player.uuid, + ) ?: return@execute + receiver.receive( + invitation = invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + 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, + ) + 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..5824e04dc --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt @@ -0,0 +1,37 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import net.minecraft.resources.ResourceLocation +import net.minecraft.network.FriendlyByteBuf + +data class FriendCardPayload( + val invitation: String, +) { + 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) + } + + fun decode(buffer: FriendlyByteBuf): FriendCardPayload = + FriendCardPayload(buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS)) +} + +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..cd65ba8ba --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt @@ -0,0 +1,178 @@ +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.DirectOnlineAuthenticationRequired +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.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 { + @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 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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + 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 = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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/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..ad23de8a3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt @@ -0,0 +1,1192 @@ +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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page +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.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: String? = 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.safeMessage } + } + 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.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.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.description"), + 34, + ), + ) + + val state = friends.state.value + 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 = MAX_VISIBLE_RELATIONSHIPS, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + 82, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = 58 + index * 26 + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } + } + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20).build().apply { + setTooltip(pageTooltip) + }, + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds(width / 2 + 131, 14, 24, 20).build().apply { + setTooltip(pageTooltip) + }, + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 76), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + 34, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 84, + 310, + 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() + } + }, + ) + offlineMode = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 112, + 310, + 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( + width / 2 - 155, + 134, + 310, + 20, + Component.translatable("connect_share.join.internet"), + internetSelected, + { selected -> internetSelected = selected }, + ).apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + }, + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setValue(nameValue.ifBlank { friend.displayName }) + setResponder { + nameValue = it + refresh() + } + }, + ) + val notify = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 82, + 310, + 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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 104, + 310, + 20, + Component.translatable("connect_share.friends.share_worlds"), + friend.permissions.canSeeMyWorlds, + ), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 154), + ) + } + } + 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.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ), + ) + 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 53, height - 28, 102, 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.safeMessage + 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, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + 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.safeMessage + 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.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + + 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(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + 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.copy_my_link"), + 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..ea50255ba --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt @@ -0,0 +1,108 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft!!.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + y, + 310, + 20, + Component.translatable("connect_share.privacy.$key"), + selected, + changed, + ), + ) + } +} 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..b8fd99d56 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt @@ -0,0 +1,151 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.CycleButton +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 + + override fun init() { + val current = viewModel.state.value + minecraft!!.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.allowCommands) + } + + addRenderableWidget(centered(title, 18)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 36, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + ).withValues(ShareGameMode.entries) + .withInitialValue(current.options.gameMode) + .create( + width / 2 - 155, + 68, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 68, + 150, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + ).withValues((1..16).toList()) + .withInitialValue(current.options.maxGuests) + .create( + width / 2 - 75, + 96, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 126, + 310, + 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", + ), + ), + ) + }, + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ), + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft!!.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, message, font) + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} + +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..cc1b4b760 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt @@ -0,0 +1,210 @@ +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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 14)) + + val sharing = state.shareState as? ShareState.Sharing + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } + addRenderableWidget( + centered(summary, 32), + ) + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft!!.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 50, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { + sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds(width / 2 + 5, 50, 150, 20).build(), + ) + copyAddress.active = sharing?.address != null + + if (sharing != null) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.link_help", + ), + 78, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft!!.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + + val pending = state.pendingAdmissions + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 124 + index * 26 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 124 + visibleRows * 26, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 128, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft!!.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} 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..f9829e240 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt @@ -0,0 +1,149 @@ +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 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, + ) + } 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, +) : 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..35817fbb1 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde 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": "Freundschafts- und Beitrittsanfragen", + "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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_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" +} 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..2d38a7018 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "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": "Friends appear here when they're ready to play.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", + "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.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_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 friend", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the 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 once", + "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" +} 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..4bc15a011 --- /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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" 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\": " + + "\"Request to %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %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..7e8e41b0d --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +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 + +class FriendCardPayloadTest { + @Test + fun `signed friend card survives the network payload codec`() { + val invitation = "minekube://share/" + "signed-card".repeat(500) + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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/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..ed2ffbc12 --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt @@ -0,0 +1,125 @@ +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.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 + + 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 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, + ): 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..8b962a75f --- /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 = 90L * 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 90 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..da981f25c --- /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,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..a89542e96 --- /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,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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..dec11f361 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } +} 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..3570aa65e --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft!!.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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..d24a6ae3c --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt @@ -0,0 +1,482 @@ +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.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()?.probeLan( + 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + + 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..ddc95d129 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.setFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..3e71098d5 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt @@ -0,0 +1,96 @@ +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, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + 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), + ) + 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..19d094f6e --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +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, +) : 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 -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + ) + }, + ) + } +} + +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..d9cb7576b --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt @@ -0,0 +1,179 @@ +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.DirectOnlineAuthenticationRequired +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.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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + 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 = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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..151c75af9 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt @@ -0,0 +1,1187 @@ +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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page +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.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: String? = 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.safeMessage } + } + 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.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.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.description"), + 34, + ), + ) + + val state = friends.state.value + 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 = MAX_VISIBLE_RELATIONSHIPS, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + 82, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = 58 + index * 26 + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } + } + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20) + .tooltip(pageTooltip) + .build(), + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds(width / 2 + 131, 14, 24, 20) + .tooltip(pageTooltip) + .build(), + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 76), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + 34, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 84, + 310, + 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() + } + }, + ) + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(width / 2 - 155, 112) + .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(width / 2 - 155, 134) + .selected(internetSelected) + .onValueChange { _, selected -> + internetSelected = selected + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 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(width / 2 - 155, 82) + .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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 154), + ) + } + } + 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.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ), + ) + 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 53, height - 28, 102, 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.safeMessage + 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, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + 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.safeMessage + 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(), + 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, + client, + 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.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + + 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(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + 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.copy_my_link"), + 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..ca93228a5 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +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.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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft!!.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(width / 2 - 155, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } +} 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..5453b9e9d --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt @@ -0,0 +1,149 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +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.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 + + override fun init() { + val current = viewModel.state.value + minecraft!!.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + + addRenderableWidget(centered(title, 18)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 36, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + ).withValues(ShareGameMode.entries) + .withInitialValue(current.options.gameMode) + .create( + width / 2 - 155, + 68, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 68, + 150, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + ).withValues((1..16).toList()) + .withInitialValue(current.options.maxGuests) + .create( + width / 2 - 75, + 96, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(width / 2 - 155, 126) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ), + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft!!.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, message, font) + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} + +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..7ac012a5b --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt @@ -0,0 +1,210 @@ +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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 14)) + + val sharing = state.shareState as? ShareState.Sharing + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } + addRenderableWidget( + centered(summary, 32), + ) + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft!!.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 50, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { + sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds(width / 2 + 5, 50, 150, 20).build(), + ) + copyAddress.active = sharing?.address != null + + if (sharing != null) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.link_help", + ), + 78, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft!!.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + + val pending = state.pendingAdmissions + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 124 + index * 26 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 124 + visibleRows * 26, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 128, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft!!.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} 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..12bef8b02 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt @@ -0,0 +1,149 @@ +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 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, + ) + } 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, +) : 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..35817fbb1 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde 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": "Freundschafts- und Beitrittsanfragen", + "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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_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" +} 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..2d38a7018 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "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": "Friends appear here when they're ready to play.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", + "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.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_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 friend", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the 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 once", + "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" +} 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..1d972d435 --- /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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" 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\": " + + "\"Request to %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %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..7fdf17982 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import io.netty.buffer.Unpooled +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 buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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..a8d3ed5b8 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt @@ -0,0 +1,125 @@ +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.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 + + 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 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, + ): 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 index b3bfb8feb..2f992cacb 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { exclude(group = "io.netty") exclude(group = "org.jetbrains.kotlin") exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") } testImplementation(kotlin("test")) @@ -143,3 +144,16 @@ tasks.remapJar { 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 = 90L * 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 90 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } 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..e048d7291 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } +} 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..be256e22a --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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 index cf23b2c50..7476d1671 100644 --- 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 @@ -13,15 +13,23 @@ 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.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 @@ -43,6 +51,10 @@ 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 { @@ -66,9 +78,33 @@ class ConnectShare12111Client : ClientModInitializer { 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) @@ -97,12 +133,14 @@ class ConnectShare12111Client : ClientModInitializer { 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, @@ -193,6 +231,18 @@ class ConnectShare12111Client : ClientModInitializer { 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, @@ -200,6 +250,8 @@ class ConnectShare12111Client : ClientModInitializer { .shareState is ShareState.Sharing, worldName = worldNameSnapshot.get(), externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, ), ) ConnectShareClient.integratedWorldChanged( @@ -238,6 +290,7 @@ class ConnectShare12111Client : ClientModInitializer { ) 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, @@ -266,6 +319,131 @@ class ConnectShare12111Client : ClientModInitializer { 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.toastManager, + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 87f039212..60fd509ae 100644 --- 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 @@ -3,7 +3,7 @@ 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.FriendJoinApproval +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 @@ -12,8 +12,12 @@ 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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page 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 @@ -25,6 +29,7 @@ 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 @@ -65,6 +70,7 @@ class ShareJoinScreen( 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() @@ -151,16 +157,21 @@ class ShareJoinScreen( ) val state = friends.state.value - val incoming = state.incomingRequests.take( - MAX_VISIBLE_RELATIONSHIPS, - ) - val outgoing = state.outgoingRequests.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size, - ) - val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, + 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 = MAX_VISIBLE_RELATIONSHIPS, ) - if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { + relationshipOffset = page.offset + if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), @@ -168,123 +179,45 @@ class ShareJoinScreen( ), ) } - incoming.forEachIndexed { index, request -> + page.items.forEachIndexed { index, relationship -> val y = 58 + index * 26 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 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, - request.ingress.displayName(), - ), - font, - ).setMaxWidth(174), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.allow"), - ) { - ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.deny"), - ) { - ConnectShareClient.viewModel().deny(request.requestId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } } - outgoing.forEachIndexed { index, request -> - val y = 58 + (incoming.size + index) * 26 - val deliveryState = requestStates[request.peerId] - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 20, - outgoingRequestLabel( - request.displayName, - deliveryState, - ), - font, + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, ), ) - addRenderableWidget( - Button.builder( - Component.translatable( - deliveryState?.translationKey - ?: "connect_share.friends.retry_request", - ), - ) { - deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { - active = deliveryState == null || - deliveryState == RequestDeliveryState.FAILED - }, - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.cancel_request", - ), - ) { - cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) - } - saved.forEachIndexed { index, friend -> - val y = - 58 + (incoming.size + outgoing.size + index) * 26 - val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 242 - actionWidth, - 20, - friendLabel(friend), - font, - ).setMaxWidth(242 - actionWidth), + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) - if (actionWidth > 0) { - addRenderableWidget( - Button.builder( - Component.translatable( - if (friend.canRequestJoin) { - "connect_share.friends.request_join" - } else { - "connect_share.join.join" - }, - ), - ) { - if (friend.canRequestJoin) requestToJoin(friend.peerId) - else joinSaved(friend.peerId) - }.bounds(width / 2 + 1, y, 86, 20).build(), - ) - } - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds(width / 2 + 131, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) + next.active = page.hasNext } safeMessage().let { message -> @@ -319,13 +252,145 @@ class ShareJoinScreen( rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .bounds(width / 2 + 5, height - 28, 150, 20) .build(), ) } + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + private fun buildAddFriend() { addRenderableWidget( centered( @@ -497,20 +562,23 @@ class ShareJoinScreen( .selected(friend.permissions.notifyWhenOnline) .build(), ) - val autoJoin = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.friends.auto_join"), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.permissions.canJoinAutomatically) - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.friends.auto_join.tooltip", - ), - ), - ) - .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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, ) val shareWorlds = addRenderableWidget( Checkbox.builder( @@ -543,8 +611,7 @@ class ShareJoinScreen( FriendPermissions( notifyWhenOnline = notify.selected(), canSeeMyWorlds = shareWorlds.selected(), - canJoinAutomatically = - autoJoin.selected(), + accessPolicy = accessPolicy, ), ) } @@ -609,13 +676,33 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { removeConfirmation = false rebuildWidgets() - }.bounds(width / 2 + 5, height - 28, 150, 20).build(), + }.bounds(width / 2 + 53, height - 28, 102, 20).build(), ) } @@ -664,7 +751,10 @@ class ShareJoinScreen( } } - private fun requestToJoin(peerId: String) { + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { if (joining) return joining = true joiningPeerId = peerId @@ -672,55 +762,37 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - val target = friends.routeFriendControl( + ConnectShareClient.friendJoinOrchestrator().request( peerId, - browser, - DirectP2pAuthMode.OFFLINE, - ).getOrNull() - if (target == null) { - joining = false - safeMessage = Component.translatable( - "connect_share.friends.friend_unreachable", - ).string - rebuildWidgets() - return@launch - } - ConnectShareClient.friendRequestClient().requestJoin( - target, FriendJoinRequest( requestId = UUID.randomUUID(), playerName = minecraft.user.name, playerUuid = minecraft.user.profileId, ), + allowModMismatch = allowModMismatch, ).fold( ifLeft = { failure -> joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, - ifRight = { approval -> - when (approval) { - is FriendJoinApproval.ExternalServer -> - connect(GuestJoinTarget.Connect(approval.address)) - FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.safeMessage + rebuildWidgets() } }, + ifRight = ::connect, ) } } - private suspend fun joinApprovedWorld(peerId: String) { - friends.join( - peerId = peerId, - browser = browser, - authMode = authMode(), - ownConnectAddress = ConnectShareClient.connectPublicAddress(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, - ) - } - private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -1078,6 +1150,20 @@ class ShareJoinScreen( 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, ) { 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..f211e2d40 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +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.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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(width / 2 - 155, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } +} 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 index f3c900d47..3bf49f46b 100644 --- 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 @@ -103,6 +103,13 @@ class ShareSetupScreen( minecraft.setScreen(ShareStatusScreen(parent)) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { onClose() } .bounds(width / 2 + 5, height - 28, 150, 20) 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 index 387f40dce..fa4f627ae 100644 --- 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 @@ -77,10 +77,15 @@ class ShareStatusScreen( ) } + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions 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 index c5885433f..35817fbb1 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", @@ -60,11 +61,20 @@ "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 sein Connect-Endpunkt ist nicht erreichbar.", + "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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } 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 index 3daee1fb2..2d38a7018 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", @@ -60,11 +61,20 @@ "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 their Connect endpoint unavailable.", + "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.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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index c687ff810..1bc566ca0 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { exclude(group = "io.netty") exclude(group = "org.jetbrains.kotlin") exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") } testImplementation(kotlin("test")) @@ -144,3 +145,16 @@ tasks.test { .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 = 90L * 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 90 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } 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..25578a66f --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } +} 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..0fb980014 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft.gui.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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 index 3de8ee6ce..43492fc96 100644 --- 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 @@ -13,15 +13,23 @@ 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.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 @@ -43,6 +51,10 @@ 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 { @@ -66,9 +78,33 @@ class ConnectShare262Client : ClientModInitializer { 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) @@ -97,12 +133,14 @@ class ConnectShare262Client : ClientModInitializer { 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, @@ -193,6 +231,18 @@ class ConnectShare262Client : ClientModInitializer { 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, @@ -200,6 +250,8 @@ class ConnectShare262Client : ClientModInitializer { .shareState is ShareState.Sharing, worldName = worldNameSnapshot.get(), externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, ), ) ConnectShareClient.integratedWorldChanged( @@ -238,6 +290,7 @@ class ConnectShare262Client : ClientModInitializer { ) 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(), @@ -266,6 +319,131 @@ class ConnectShare262Client : ClientModInitializer { 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.gui.toastManager(), + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 0321f2f33..78bb37090 100644 --- 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 @@ -3,7 +3,7 @@ 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.FriendJoinApproval +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 @@ -12,8 +12,12 @@ 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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page 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 @@ -25,6 +29,7 @@ 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 @@ -65,6 +70,7 @@ class ShareJoinScreen( 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() @@ -151,16 +157,21 @@ class ShareJoinScreen( ) val state = friends.state.value - val incoming = state.incomingRequests.take( - MAX_VISIBLE_RELATIONSHIPS, - ) - val outgoing = state.outgoingRequests.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size, - ) - val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, + 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 = MAX_VISIBLE_RELATIONSHIPS, ) - if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { + relationshipOffset = page.offset + if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), @@ -168,123 +179,45 @@ class ShareJoinScreen( ), ) } - incoming.forEachIndexed { index, request -> + page.items.forEachIndexed { index, relationship -> val y = 58 + index * 26 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 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, - request.ingress.displayName(), - ), - font, - ).setMaxWidth(174), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.allow"), - ) { - ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.deny"), - ) { - ConnectShareClient.viewModel().deny(request.requestId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } } - outgoing.forEachIndexed { index, request -> - val y = 58 + (incoming.size + index) * 26 - val deliveryState = requestStates[request.peerId] - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 20, - outgoingRequestLabel( - request.displayName, - deliveryState, - ), - font, + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, ), ) - addRenderableWidget( - Button.builder( - Component.translatable( - deliveryState?.translationKey - ?: "connect_share.friends.retry_request", - ), - ) { - deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { - active = deliveryState == null || - deliveryState == RequestDeliveryState.FAILED - }, - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.cancel_request", - ), - ) { - cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) - } - saved.forEachIndexed { index, friend -> - val y = - 58 + (incoming.size + outgoing.size + index) * 26 - val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 242 - actionWidth, - 20, - friendLabel(friend), - font, - ).setMaxWidth(242 - actionWidth), + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) - if (actionWidth > 0) { - addRenderableWidget( - Button.builder( - Component.translatable( - if (friend.canRequestJoin) { - "connect_share.friends.request_join" - } else { - "connect_share.join.join" - }, - ), - ) { - if (friend.canRequestJoin) requestToJoin(friend.peerId) - else joinSaved(friend.peerId) - }.bounds(width / 2 + 1, y, 86, 20).build(), - ) - } - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds(width / 2 + 131, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) + next.active = page.hasNext } safeMessage().let { message -> @@ -319,13 +252,145 @@ class ShareJoinScreen( rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .bounds(width / 2 + 5, height - 28, 150, 20) .build(), ) } + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + private fun buildAddFriend() { addRenderableWidget( centered( @@ -497,20 +562,23 @@ class ShareJoinScreen( .selected(friend.permissions.notifyWhenOnline) .build(), ) - val autoJoin = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.friends.auto_join"), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.permissions.canJoinAutomatically) - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.friends.auto_join.tooltip", - ), - ), - ) - .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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, ) val shareWorlds = addRenderableWidget( Checkbox.builder( @@ -543,8 +611,7 @@ class ShareJoinScreen( FriendPermissions( notifyWhenOnline = notify.selected(), canSeeMyWorlds = shareWorlds.selected(), - canJoinAutomatically = - autoJoin.selected(), + accessPolicy = accessPolicy, ), ) } @@ -609,13 +676,33 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { removeConfirmation = false rebuildWidgets() - }.bounds(width / 2 + 5, height - 28, 150, 20).build(), + }.bounds(width / 2 + 53, height - 28, 102, 20).build(), ) } @@ -664,7 +751,10 @@ class ShareJoinScreen( } } - private fun requestToJoin(peerId: String) { + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { if (joining) return joining = true joiningPeerId = peerId @@ -672,55 +762,37 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - val target = friends.routeFriendControl( + ConnectShareClient.friendJoinOrchestrator().request( peerId, - browser, - DirectP2pAuthMode.OFFLINE, - ).getOrNull() - if (target == null) { - joining = false - safeMessage = Component.translatable( - "connect_share.friends.friend_unreachable", - ).string - rebuildWidgets() - return@launch - } - ConnectShareClient.friendRequestClient().requestJoin( - target, FriendJoinRequest( requestId = UUID.randomUUID(), playerName = minecraft.user.name, playerUuid = minecraft.user.profileId, ), + allowModMismatch = allowModMismatch, ).fold( ifLeft = { failure -> joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, - ifRight = { approval -> - when (approval) { - is FriendJoinApproval.ExternalServer -> - connect(GuestJoinTarget.Connect(approval.address)) - FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft.gui.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.safeMessage + rebuildWidgets() } }, + ifRight = ::connect, ) } } - private suspend fun joinApprovedWorld(peerId: String) { - friends.join( - peerId = peerId, - browser = browser, - authMode = authMode(), - ownConnectAddress = ConnectShareClient.connectPublicAddress(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, - ) - } - private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -1077,6 +1149,20 @@ class ShareJoinScreen( 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, ) { 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..c5f3f5b4c --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +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.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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft.gui.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(width / 2 - 155, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } +} 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 index db90bca2e..5c92adf39 100644 --- 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 @@ -103,6 +103,13 @@ class ShareSetupScreen( minecraft.gui.setScreen(ShareStatusScreen(parent)) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { onClose() } .bounds(width / 2 + 5, height - 28, 150, 20) 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 index 7de617042..a86bc984c 100644 --- 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 @@ -77,10 +77,15 @@ class ShareStatusScreen( ) } + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions 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 index c5885433f..35817fbb1 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", @@ -60,11 +61,20 @@ "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 sein Connect-Endpunkt ist nicht erreichbar.", + "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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } 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 index 3daee1fb2..2d38a7018 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", @@ -60,11 +61,20 @@ "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 their Connect endpoint unavailable.", + "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.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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts index 000e4d29b..c3354ae2f 100644 --- a/share/fabric-common/build.gradle.kts +++ b/share/fabric-common/build.gradle.kts @@ -1,9 +1,13 @@ +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) } @@ -11,6 +15,7 @@ java { kotlin { jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } dependencies { 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 index 30597c0b4..7c8e7e5eb 100644 --- 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 @@ -25,6 +25,10 @@ data class ConnectShareInstallation( 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, @@ -113,6 +117,15 @@ object ConnectShareClient { 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() 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 index 278d256a5..2e625f0f5 100644 --- 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 @@ -22,23 +22,26 @@ internal class FabricDirectPeerRuntime private constructor( ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( - browser = FabricShareBrowser(dataDirectory), - ingress = FabricDirectShareIngress( - dataDirectory = dataDirectory, - displayName = displayName, - accessIdentityStore = accessIdentityStore, + 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, ), ) @@ -53,6 +56,8 @@ internal class FabricDirectPeerRuntime private constructor( dataDirectory = dataDirectory, displayName = displayName, ) + + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" } } 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 index 7acf402ef..2cda42bfb 100644 --- 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 @@ -29,6 +29,7 @@ class FabricDirectShareIngress private constructor( private val accessIdentity: () -> ShareAccessIdentity, private val displayName: () -> String, private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, + private val closeNodeOnHandleClose: Boolean, ) : DirectShareIngress { constructor( dataDirectory: Path, @@ -45,20 +46,22 @@ class FabricDirectShareIngress private constructor( accessIdentity = accessIdentityStore::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, + closeNodeOnHandleClose = true, ) internal constructor( node: FabricDirectNode, dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( nodeFactory = { node }, now = Instant::now, - accessIdentity = ShareAccessIdentityStore( - dataDirectory, - )::currentOrCreate, + accessIdentity = accessIdentityStore::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, + closeNodeOnHandleClose = false, ) override suspend fun start( @@ -119,17 +122,22 @@ class FabricDirectShareIngress private constructor( options.allowInternetDirect && internetCandidates.isNotEmpty(), close = { - if (closed.compareAndSet(false, true)) { + if ( + closeNodeOnHandleClose && + closed.compareAndSet(false, true) + ) { node.close() } }, ) } catch (failure: Throwable) { - try { - node.close() - } catch (cleanupFailure: Throwable) { - if (cleanupFailure !== failure) { - failure.addSuppressed(cleanupFailure) + if (closeNodeOnHandleClose) { + try { + node.close() + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } } } throw failure @@ -155,6 +163,7 @@ class FabricDirectShareIngress private constructor( }, displayName = displayName, localSocket = localSocket, + closeNodeOnHandleClose = true, ) private fun openTaggedLoopbackSocket( 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 index 01dd48408..7b7985a2d 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -42,6 +43,7 @@ object FabricShareBootstrap { scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, + modVersion: String = "development", worldAvailable: Boolean, friendStore: FriendStore, playerCount: () -> Int, @@ -50,6 +52,7 @@ object FabricShareBootstrap { friendActivity: () -> FriendActivity = { FriendActivity(FriendActivityKind.ONLINE) }, + compatibilityProfile: () -> CompatibilityProfile? = { null }, friendJoinTarget: () -> String? = { null }, bridgeFactory: ( @@ -65,6 +68,7 @@ object FabricShareBootstrap { httpClient: OkHttpClient = OkHttpClient(), ): ConnectShareInstallation { val viewModelReference = AtomicReference() + val diagnostics = ShareJoinDiagnostics() val approvedJoins = ApprovedJoinTracker() val admission = AdmissionController( scope = scope, @@ -103,6 +107,7 @@ object FabricShareBootstrap { logger.warn("Connect Share preferences could not be loaded") SharePreferences() } + val preferences = AtomicReference(initialPreferences) val validator = WatchEndpointCredentialValidator( client = httpClient, watchUrl = watchHttpUrl(environment), @@ -127,6 +132,7 @@ object FabricShareBootstrap { receiver = friendCardReceiver, friendStore = friendStore, activity = friendActivity, + presencePrivacy = { preferences.get().presence }, joinTarget = friendJoinTarget, ) val gateway = ShareConnectionGateway.bind(friendRequestServer) @@ -200,10 +206,18 @@ object FabricShareBootstrap { initialWorldAvailable = worldAvailable, initialShareWithFriendsEnabled = initialPreferences.shareWithFriends, + initialPresencePrivacy = initialPreferences.presence, persistShareWithFriendsEnabled = { enabled -> - preferencesStore.save( - SharePreferences(shareWithFriends = 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, @@ -293,6 +307,15 @@ object FabricShareBootstrap { 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, @@ -301,6 +324,10 @@ object FabricShareBootstrap { friendCardReceiver = friendCardReceiver, friendRequestClient = friendRequestClient, friendPairingClient = friendPairingClient, + friendJoinOrchestrator = friendJoinOrchestrator, + diagnostics = diagnostics, + minecraftVersion = minecraftVersion, + modVersion = modVersion, approvedJoins = approvedJoins, controlPlane = controlPlane, directControlPlane = directControlPlane, 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/FriendActivityResolver.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt index c1707ba5f..6874c1481 100644 --- 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 @@ -2,6 +2,7 @@ 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( @@ -9,15 +10,24 @@ object FriendActivityResolver { 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, ) - else -> FriendActivity(FriendActivityKind.ONLINE) } } 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/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt index c30378f18..14acae935 100644 --- 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 @@ -15,8 +15,10 @@ 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 @@ -40,6 +42,9 @@ class FriendRequestServer( private val activity: () -> FriendActivity = { FriendActivity(FriendActivityKind.ONLINE) }, + private val presencePrivacy: () -> PresencePrivacy = { + PresencePrivacy() + }, private val joinTarget: () -> String? = { null }, ) : FriendControlServer { override fun handle( @@ -101,10 +106,24 @@ class FriendRequestServer( ): CompletionStage = launchResponse { val friend = authenticatedFriend(context) ?: return@launchResponse FriendControlResponse.Invalid - val visible = if (friend.permissions.canSeeMyWorlds) { - activity() - } else { - FriendActivity(FriendActivityKind.ONLINE) + 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 || + privacy.showCurrentServer + }, + joinable = current.joinable && privacy.showJoinable && + friend.permissions.accessPolicy != + FriendAccessPolicy.NEVER_ALLOW, + ) } FriendControlResponse.Activity(visible) } @@ -115,6 +134,9 @@ class FriendRequestServer( ): 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 } @@ -196,6 +218,9 @@ class FriendRequestServer( 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() 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/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/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt index 360eb282b..ae282ce67 100644 --- 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 @@ -13,6 +13,8 @@ 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 @@ -20,6 +22,7 @@ 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 @@ -39,6 +42,7 @@ data class FriendSummary( val activityDescription: String? = null, val canRequestJoin: Boolean = false, val canJoinNow: Boolean = false, + val following: Boolean = false, ) data class OutgoingFriendRequestSummary( @@ -53,15 +57,23 @@ data class IncomingFriendRequestSummary( 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: String? = null, ) class FriendsViewModel( private val store: FriendStore, + private val followController: FollowNextSessionController = + FollowNextSessionController(), private val onRemovalQueued: () -> Unit = {}, ) { private var discovered: List = emptyList() @@ -139,6 +151,59 @@ class FriendsViewModel( }, ) + fun block(peerId: String): Boolean = + Either.catch { store.block(peerId) }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_BLOCK_FAILURE) } + false + }, + ifRight = { blocked -> + refresh() + if (blocked) 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 @@ -236,6 +301,9 @@ class FriendsViewModel( } }.getOrNull() + internal fun compatibilityFor(peerId: String): CompatibilityProfile? = + activities[peerId]?.compatibility + private fun refresh( preserveSafeMessage: Boolean = false, ) { @@ -273,6 +341,12 @@ class FriendsViewModel( ) }, incomingRequests = incomingRequests, + blocked = store.blocked().map { + BlockedFriendSummary( + peerId = it.peerId, + displayName = it.displayName, + ) + }, ) private fun update(transform: FriendsUiState.() -> FriendsUiState) { @@ -293,13 +367,16 @@ class FriendsViewModel( worldName = remote?.description, activityKind = activity?.kind, activityDescription = activity?.description, - canRequestJoin = - activity?.kind == FriendActivityKind.PLAYING_SERVER || - activity?.kind == FriendActivityKind.HOSTING_WORLD && - remote != null, + canRequestJoin = activity?.joinable == true && + ( + activity.kind == FriendActivityKind.PLAYING_SERVER || + activity.kind == FriendActivityKind.HOSTING_WORLD && + remote != null + ), canJoinNow = remote != null && activity?.kind != FriendActivityKind.PLAYING_SERVER && activity?.kind != FriendActivityKind.HOSTING_WORLD, + following = peerId in followController.state.value, ) } @@ -308,5 +385,9 @@ class FriendsViewModel( "Saved Connect Share friends could not be loaded" const val FRIEND_REMOVE_FAILURE = "This Connect Share friend could not be removed" + const val FRIEND_BLOCK_FAILURE = + "This Connect Share identity could not be blocked" + const val FRIEND_UNBLOCK_FAILURE = + "This Connect Share identity could not be unblocked" } } 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/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt index 52b58f751..75c775e09 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -23,6 +24,8 @@ 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, @@ -52,6 +55,7 @@ data class ShareUiState( 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, @@ -112,6 +116,8 @@ class ShareViewModel( 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, @@ -119,6 +125,7 @@ class ShareViewModel( private val operationDispatcher: CoroutineDispatcher = Dispatchers.IO, private val onIdentityChanged: suspend () -> Unit = {}, ) { + private val operationMutex = Mutex() private val mutableState = MutableStateFlow( ShareUiState( worldAvailable = initialWorldAvailable, @@ -129,6 +136,7 @@ class ShareViewModel( ), pendingAdmissions = pendingAdmissions.value, shareWithFriendsEnabled = initialShareWithFriendsEnabled, + presencePrivacy = initialPresencePrivacy, ), ) @@ -189,10 +197,22 @@ class ShareViewModel( } } + 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() } @@ -202,6 +222,7 @@ class ShareViewModel( fun stop() { scope.launch(context = operationDispatcher) { runOperation { + if (!canStopCurrentWorld()) return@runOperation try { setShareWithFriendsEnabled(false) } finally { @@ -220,6 +241,7 @@ class ShareViewModel( } kotlinx.coroutines.withContext(operationDispatcher) { runOperation { + if (!canStartCurrentWorld()) return@runOperation startCurrentWorld() } } @@ -343,7 +365,12 @@ class ShareViewModel( update { copy(safeMessage = failure.safeMessage) } }, ifRight = { - update { copy(safeMessage = null) } + update { + copy( + shareState = it, + safeMessage = null, + ) + } }, ) } @@ -354,24 +381,45 @@ class ShareViewModel( update { copy(safeMessage = failure.safeMessage) } }, ifRight = { - update { copy(safeMessage = null) } + update { + copy( + shareState = ShareState.Idle, + safeMessage = null, + ) + } }, ) } private suspend fun runOperation(operation: suspend () -> Unit) { - update { copy(operationInProgress = true) } - try { - operation() - } catch (cancellation: CancellationException) { - throw cancellation - } catch (_: Exception) { - update { copy(safeMessage = GENERIC_FAILURE_MESSAGE) } - } finally { - update { copy(operationInProgress = false) } + 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, + is ShareState.Failed, + -> false + + ShareState.Starting, + is ShareState.Sharing, + ShareState.Stopping, + -> true + } + private fun update(transform: ShareUiState.() -> ShareUiState) { mutableState.value = mutableState.value.transform() } @@ -409,6 +457,8 @@ class ShareViewModel( "Could not update Connect Share" const val IDENTITY_ACTIVE_MESSAGE = "Stop sharing before changing Connect credentials" + const val PREFERENCES_FAILURE_MESSAGE = + "Connect Share privacy settings could not be saved" } } 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 index 3b5da75e8..9db41151c 100644 --- 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 @@ -54,12 +54,57 @@ class FabricDirectPeerRuntimeTest { 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 @@ -99,7 +144,9 @@ class FabricDirectPeerRuntimeTest { timeout: Duration, ): DirectP2pProxy = error("not used") - override fun close() = Unit + override fun close() { + closes++ + } } private companion object { 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..ea9e961a9 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt @@ -0,0 +1,128 @@ +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 `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/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/FriendRequestServerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt index 8d71d47dc..a72eb6767 100644 --- 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 @@ -13,6 +13,8 @@ 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 @@ -116,6 +118,35 @@ class FriendRequestServerTest { 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 { @@ -327,6 +358,79 @@ class FriendRequestServerTest { ) } + @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(), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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..8defd857e --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt @@ -0,0 +1,49 @@ +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 + +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) + } +} 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 index 70bab6180..998aad2c3 100644 --- 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 @@ -4,6 +4,7 @@ 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.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -12,6 +13,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.test.fail import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -42,6 +44,16 @@ class PrismFriendJoinE2ETest { .lineSequence() .count { joinedLine in it } 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()) @@ -57,15 +69,19 @@ class PrismFriendJoinE2ETest { friend, DirectP2pAuthMode.OFFLINE, ).getOrNull()!! - assertEquals( - FriendActivityKind.HOSTING_WORLD, + val activityResult = activityTarget.use { client.activity( it, com.minekube.connect.share.friend .FriendActivityRequest(UUID.randomUUID()), - ).getOrNull()?.kind - }, + ) + } + assertEquals( + FriendActivityKind.HOSTING_WORLD, + activityResult.getOrNull()?.kind + ?: fail(activityResult.leftOrNull()?.safeMessage + ?: "Host returned no friend activity"), ) // Status and gameplay require different one-shot proxies. 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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt index 9571308ed..ae367a183 100644 --- 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 @@ -212,6 +212,24 @@ class FriendsViewModelTest { 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() @@ -313,6 +331,51 @@ class FriendsViewModelTest { 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) 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/ShareViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt index f25f82769..0750e4db5 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -177,6 +178,31 @@ class ShareViewModelTest { 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( @@ -231,6 +257,27 @@ class ShareViewModelTest { 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), @@ -245,6 +292,7 @@ class ShareViewModelTest { answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, initialShareWithFriends: Boolean = false, persistShareWithFriends: (Boolean) -> Unit = {}, + persistPresencePrivacy: (PresencePrivacy) -> Unit = {}, startShare: suspend (ShareOptions) -> Either = { options -> @@ -264,6 +312,7 @@ class ShareViewModelTest { initialShareWithFriendsEnabled = initialShareWithFriends, operationDispatcher = operationDispatcher, persistShareWithFriendsEnabled = persistShareWithFriends, + persistPresencePrivacy = persistPresencePrivacy, startShare = startShare, stopShare = { Either.Right(Unit) }, answerAdmission = answerAdmission, 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..ed8606e95 --- /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 = 90L * 1024L * 1024L + check(bytes <= limit) { + "Connect Share Forge 1.20.1 exceeds the 90 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..4aaef2e69 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt @@ -0,0 +1,78 @@ +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.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 { + 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 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, + ) = Unit + + @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/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..5e4d5efa4 --- /dev/null +++ b/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt @@ -0,0 +1,55 @@ +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")) + 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/neoforge-1.21.1/build.gradle.kts b/share/neoforge-1.21.1/build.gradle.kts new file mode 100644 index 000000000..dd8ac965f --- /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 = 90L * 1024L * 1024L + check(bytes <= limit) { + "Connect Share NeoForge 1.21.1 exceeds the 90 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..03fc86836 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt @@ -0,0 +1,76 @@ +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.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 + +@Mod("connect_share") +class NeoForgeConnectShare1211Client { + private val platform = NeoForgePlatform() + + init { + 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, + ) = Unit + + @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/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..3a680c28e --- /dev/null +++ b/share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt @@ -0,0 +1,42 @@ +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("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") + } + } +} From c0328b6c175bb426776e8d2de9cd860b22b721a8 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 22:08:49 +0200 Subject: [PATCH 049/188] no-mistakes(review): Hardened admission, cleanup, renewal, and loader networking --- .../share/admission/AdmissionController.kt | 62 +++++++- .../admission/AdmissionControllerTest.kt | 64 ++++++++- .../share/fabric/FabricDirectShareIngress.kt | 112 +++++++++++---- .../share/fabric/FabricShareBootstrap.kt | 56 ++++++-- .../share/fabric/FriendRequestServer.kt | 8 +- .../share/fabric/ui/FriendsViewModel.kt | 14 +- .../fabric/FabricDirectShareIngressTest.kt | 35 +++++ .../share/fabric/FriendRequestServerTest.kt | 36 ++++- .../v1_20_1/ForgeConnectShare1201Client.kt | 7 +- .../v1_20_1/ForgeFriendCardNetworking.kt | 121 ++++++++++++++++ .../forge/v1_20_1/Forge1201ArtifactTest.kt | 6 + .../v1_21_1/NeoForgeConnectShare1211Client.kt | 21 ++- .../v1_21_1/NeoForgeFriendCardNetworking.kt | 134 ++++++++++++++++++ .../v1_21_1/NeoForge1211ArtifactTest.kt | 6 + 14 files changed, 626 insertions(+), 56 deletions(-) create mode 100644 share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt create mode 100644 share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt 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 index 8dcaa6da4..9df7af82e 100644 --- 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 @@ -23,7 +23,7 @@ class AdmissionController( ) { private val lock = Any() private val requests = linkedMapOf() - private val authenticatedApprovals = mutableSetOf() + private val authenticatedApprovals = mutableSetOf() private val preapprovedJoins = mutableSetOf() private val mutablePending = MutableStateFlow>(emptyList()) @@ -70,7 +70,7 @@ class AdmissionController( if ( purpose == AdmissionPurpose.JOIN && identity is AdmissionIdentity.Authenticated && - identity.uuid in authenticatedApprovals + authenticatedApprovals.any { it.matches(identity) } ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) } @@ -119,7 +119,11 @@ class AdmissionController( ) { val identity = entry.value.pending.identity if (identity is AdmissionIdentity.Authenticated) { - authenticatedApprovals += identity.uuid + authenticatedApprovals += AuthenticatedApproval( + uuid = identity.uuid, + directPeerId = identity.directPeerId, + ingress = identity.ingress, + ) } } publishPending() @@ -146,6 +150,31 @@ class AdmissionController( return denied.size } + fun revokeDirectPeer( + peerId: String, + minecraftUuid: UUID? = null, + ): Int { + val revoked = synchronized(lock) { + preapprovedJoins.removeIf { it.directPeerId == peerId } + authenticatedApprovals.removeIf { + it.directPeerId == peerId || + ( + it.directPeerId == null && + minecraftUuid != null && + it.uuid == minecraftUuid + ) + } + val matches = requests.entries.filter { entry -> + entry.value.pending.identity.directPeerId == peerId + } + 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() @@ -250,8 +279,31 @@ class AdmissionController( val minecraftUuid: UUID, ) { fun matches(identity: AdmissionIdentity): Boolean = - (directPeerId != null && directPeerId == identity.directPeerId) || - minecraftUuid == identity.uuid + 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( 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 index e0acd2b17..8556772b2 100644 --- 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 @@ -281,7 +281,31 @@ class AdmissionControllerTest { } @Test - fun `approved friend request also authorizes Connect fallback by player UUID`() = runTest { + 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, @@ -293,10 +317,44 @@ class AdmissionControllerTest { assertEquals( AdmissionAnswer.ALLOW, controller.request( - authenticated("RoboFlax2", AUTHENTICATED_UUID), + requestedIdentity.copy( + connectionId = "connect-gameplay", + directPeerId = null, + ingress = Ingress.CONNECT, + ), ), ) - assertTrue(controller.pending.value.isEmpty()) + } + + @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()) } private fun kotlinx.coroutines.test.TestScope.controller( 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 index 2cda42bfb..dfbfd1d77 100644 --- 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 @@ -22,6 +22,15 @@ import java.nio.file.Path import java.time.Instant import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean +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, @@ -30,6 +39,7 @@ class FabricDirectShareIngress private constructor( private val displayName: () -> String, private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, private val closeNodeOnHandleClose: Boolean, + private val renewalDispatcher: kotlinx.coroutines.CoroutineDispatcher, ) : DirectShareIngress { constructor( dataDirectory: Path, @@ -47,6 +57,7 @@ class FabricDirectShareIngress private constructor( displayName = displayName, localSocket = ::openTaggedLoopbackSocket, closeNodeOnHandleClose = true, + renewalDispatcher = kotlinx.coroutines.Dispatchers.IO, ) internal constructor( @@ -62,6 +73,7 @@ class FabricDirectShareIngress private constructor( displayName = displayName, localSocket = ::openTaggedLoopbackSocket, closeNodeOnHandleClose = false, + renewalDispatcher = kotlinx.coroutines.Dispatchers.IO, ) override suspend fun start( @@ -90,30 +102,36 @@ class FabricDirectShareIngress private constructor( } else { emptyList() } - val payload = ShareInvitePayload( - wireVersion = ShareInviteCodec.WIRE_VERSION, + val invitation = invitation( + node = node, + host = host, shareId = id, - expiresAtEpochMillis = now() - .plusSeconds(INVITATION_LIFETIME_SECONDS) - .toEpochMilli(), + secret = secret, connectAddress = connectAddress, - peerId = host.peerId(), - internetDirectEnabled = options.allowInternetDirect, - directCandidates = internetCandidates, - capability = secret, - ) - val unsigned = ShareInviteCodec.unsignedBytes( - payload, - host.publicKey(), - ) - val invitation = ShareInviteCodec.encode( - SignedShareInvite( - payload = payload, - publicKey = host.publicKey(), - signature = node.sign(unsigned), - ), + options = options, ) node.publish(invitation) + val renewalScope = CoroutineScope(SupervisorJob() + renewalDispatcher) + val renewalJob = renewalScope.launch { + while (isActive) { + delay(INVITATION_RENEWAL_MILLIS) + try { + node.publish( + invitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options, + ), + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: RuntimeException) { + } + } + } val closed = AtomicBoolean() return DirectShareHandle( invitation = invitation, @@ -122,11 +140,12 @@ class FabricDirectShareIngress private constructor( options.allowInternetDirect && internetCandidates.isNotEmpty(), close = { - if ( - closeNodeOnHandleClose && - closed.compareAndSet(false, true) - ) { - node.close() + if (closed.compareAndSet(false, true)) { + renewalJob.cancelAndJoin() + renewalScope.cancel() + if (closeNodeOnHandleClose) { + node.close() + } } }, ) @@ -144,6 +163,45 @@ class FabricDirectShareIngress private constructor( } } + private fun invitation( + 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, @@ -152,6 +210,8 @@ class FabricDirectShareIngress private constructor( capability: () -> String, displayName: () -> String, localSocket: (SocketAddress, DirectP2pSession) -> Socket, + renewalDispatcher: kotlinx.coroutines.CoroutineDispatcher = + kotlinx.coroutines.Dispatchers.IO, ) = FabricDirectShareIngress( nodeFactory = nodeFactory, now = now, @@ -164,6 +224,7 @@ class FabricDirectShareIngress private constructor( displayName = displayName, localSocket = localSocket, closeNodeOnHandleClose = true, + renewalDispatcher = renewalDispatcher, ) private fun openTaggedLoopbackSocket( @@ -202,6 +263,7 @@ class FabricDirectShareIngress private constructor( 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 } } 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 index 7b7985a2d..adc13d1e8 100644 --- 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 @@ -31,9 +31,11 @@ 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 @@ -137,6 +139,8 @@ object FabricShareBootstrap { ) val gateway = ShareConnectionGateway.bind(friendRequestServer) var browser: FabricShareBrowser? = null + var controlPlane: ConnectControlPlane? = null + var directControlPlane: DirectControlPlane? = null try { val directPeer = FabricDirectPeerRuntime( dataDirectory = dataDirectory, @@ -180,14 +184,16 @@ object FabricShareBootstrap { directIngress = directIngress, failureReporter = logger::warn, ) - val controlPlane = ConnectControlPlane( + val startedControlPlane = ConnectControlPlane( scope = scope, ingress = ingress, identity = identityStore::currentOrCreate, target = gateway.serverSocketAddress, failureReporter = logger::warn, - ).also(ConnectControlPlane::start) - val directControlPlane = DirectControlPlane( + ) + controlPlane = startedControlPlane + startedControlPlane.start() + val startedDirectControlPlane = DirectControlPlane( scope = scope, ingress = directIngress, options = ShareOptions( @@ -198,7 +204,9 @@ object FabricShareBootstrap { target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, failureReporter = logger::warn, - ).also(DirectControlPlane::start) + ) + directControlPlane = startedDirectControlPlane + startedDirectControlPlane.start() val viewModel = ShareViewModel( scope = scope, shareState = coordinator.state, @@ -228,7 +236,7 @@ object FabricShareBootstrap { "${identityStore.currentOrCreate().endpoint}" + ".play.minekube.net", ) - controlPlane.restart() + startedControlPlane.restart() }, startShare = coordinator::start, stopShare = coordinator::stop, @@ -263,11 +271,21 @@ object FabricShareBootstrap { }, ) } - val friendsViewModel = FriendsViewModel(friendStore) { - scope.launch(Dispatchers.IO) { - removalSync.sync() - } - } + val friendsViewModel = FriendsViewModel( + store = friendStore, + onPeerRemoved = { peerId -> + val minecraftUuid = friendStore.pendingRemovals() + .lastOrNull { it.friend.peerId == peerId } + ?.friend + ?.minecraftUuid + admission.revokeDirectPeer(peerId, minecraftUuid) + }, + onRemovalQueued = { + scope.launch(Dispatchers.IO) { + removalSync.sync() + } + }, + ) val activityMonitor = FriendActivityMonitor( store = friendStore, query = { friend -> @@ -329,8 +347,8 @@ object FabricShareBootstrap { minecraftVersion = minecraftVersion, modVersion = modVersion, approvedJoins = approvedJoins, - controlPlane = controlPlane, - directControlPlane = directControlPlane, + controlPlane = startedControlPlane, + directControlPlane = startedDirectControlPlane, browser = activeBrowser, friendActivity = activityMonitor, gateway = gateway, @@ -339,8 +357,18 @@ object FabricShareBootstrap { guestScreens = guestScreens, ) } catch (failure: Throwable) { - browser?.close() - gateway.close() + try { + withContext(NonCancellable) { + directControlPlane?.shutdown() + controlPlane?.shutdown() + browser?.close() + gateway.close() + } + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } throw failure } } 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 index 14acae935..708cf3483 100644 --- 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 @@ -79,10 +79,10 @@ class FriendRequestServer( ) { FriendControlResponse.Invalid } else { - admission.denyDirectPeer( - peerId, - AdmissionPurpose.FRIEND, - ) + val minecraftUuid = friendStore.relationship(peerId) + .getOrNull() + ?.minecraftUuid + admission.revokeDirectPeer(peerId, minecraftUuid) if (friendStore.applyRemoteRemoval(peerId)) { notifyRelationshipChanged() } 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 index ae282ce67..10b20d92d 100644 --- 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 @@ -74,6 +74,7 @@ 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() @@ -145,6 +146,7 @@ class FriendsViewModel( ifRight = { removed -> refresh() if (removed) { + notifyPeerRemoved(peerId) onRemovalQueued() } removed @@ -159,7 +161,10 @@ class FriendsViewModel( }, ifRight = { blocked -> refresh() - if (blocked) onRemovalQueued() + if (blocked) { + notifyPeerRemoved(peerId) + onRemovalQueued() + } blocked }, ) @@ -353,6 +358,13 @@ class FriendsViewModel( mutableState.value = mutableState.value.transform() } + private fun notifyPeerRemoved(peerId: String) { + try { + onPeerRemoved(peerId) + } catch (_: RuntimeException) { + } + } + private fun SavedFriend.summary(): FriendSummary { val remote = remotePresence[peerId] ?.takeIf { it.online } 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 index 32c4c68ea..33201f914 100644 --- 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 @@ -20,9 +20,13 @@ 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 @@ -103,6 +107,35 @@ class FabricDirectShareIngressTest { handle.close() } + @Test + fun `persistent direct host republishes before its invitation expires`() = 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") }, + renewalDispatcher = StandardTestDispatcher(testScheduler), + ) + + val handle = ingress.start( + OPTIONS, + InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ), + null, + ) + runCurrent() + advanceTimeBy(12 * 60 * 60 * 1_000L) + runCurrent() + + assertTrue(node.publishedInvitations.size >= 2) + handle.close() + } + @Test fun `partial startup closes the isolated node`() = runTest { val node = FakeDirectNode(failPublish = true) @@ -181,6 +214,7 @@ class FabricDirectShareIngressTest { ), ) var published: String? = null + val publishedInvitations = mutableListOf() var closed = false override fun startHost( @@ -200,6 +234,7 @@ class FabricDirectShareIngressTest { error("publish failed") } published = invitation + publishedInvitations += invitation } override fun close() { 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 index a72eb6767..1210f739e 100644 --- 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 @@ -1,6 +1,9 @@ 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 @@ -25,6 +28,7 @@ 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 @@ -193,9 +197,32 @@ class FriendRequestServerTest { .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 server = FriendRequestServer( scope = backgroundScope, - admission = admission(), + admission = admission, issuer = issuer("host"), receiver = FriendCardReceiver(hostStore), friendStore = hostStore, @@ -218,6 +245,13 @@ class FriendRequestServerTest { ) assertTrue(hostStore.all().isEmpty()) assertTrue(hostStore.pendingRemovals().isEmpty()) + val afterRemoval = async { + admission.request(authenticated) + } + runCurrent() + assertEquals(1, admission.pending.value.size) + admission.resetShare() + assertEquals(AdmissionAnswer.STOPPED, afterRemoval.await()) } @Test 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 index 4aaef2e69..fa10c48c8 100644 --- 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 @@ -60,7 +60,12 @@ class ForgeConnectShare1201Client { issuer: FriendCardIssuer, receiver: FriendCardReceiver, approvedJoins: ApprovedJoinTracker, - ) = Unit + ) = ForgeFriendCardNetworking.install( + scope, + issuer, + receiver, + approvedJoins, + ) @SubscribeEvent fun onClientTick(event: TickEvent.ClientTickEvent) { 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..2048c9670 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt @@ -0,0 +1,121 @@ +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.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) } + .decoder { buffer -> FriendCardMessage(buffer.readUtf(MAX_CARD_CHARS)) } + .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, + ) ?: return@consumerMainThread + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.receive( + invitation = message.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + .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)) + 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, + ) + + private data object FriendCardRequestMessage +} 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 index 5e4d5efa4..ca22786c7 100644 --- 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 @@ -32,6 +32,12 @@ class Forge1201ArtifactTest { ) 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"), 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 index 03fc86836..aa0410c54 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -18,12 +19,18 @@ 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 { +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) } @@ -60,7 +67,17 @@ class NeoForgeConnectShare1211Client { issuer: FriendCardIssuer, receiver: FriendCardReceiver, approvedJoins: ApprovedJoinTracker, - ) = Unit + ) = 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) { 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..5a2de7f24 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt @@ -0,0 +1,134 @@ +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.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), + ) + 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, + ) ?: return@playToServer + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + } + + 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, +) : 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 -> FriendCardPayload(buffer.readUtf(MAX_CARD_CHARS)) }, + ) + } +} + +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/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 index 3a680c28e..1c996f580 100644 --- 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 @@ -22,6 +22,12 @@ class NeoForge1211ArtifactTest { 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/") }) From 2db157cba73fffa4c33dbc0f8951e1d849c924d1 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 23:00:30 +0200 Subject: [PATCH 050/188] no-mistakes(document): Corrected Share matrix and Java toolchain docs --- docs/connect-share-testing.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 12b65c5e3..8575685ab 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,10 +1,11 @@ # Connect Share acceptance -Connect Share is built separately for Fabric 1.20.1, 1.21.1, and 1.21.11, -Forge 1.20.1, and NeoForge 1.21.1 on a Java 21 build 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. +Connect Share is built separately for Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2, +Forge 1.20.1, and NeoForge 1.21.1 on their respective Java toolchains. 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. From 47228e454239ab3e322268a42aeab9cd3d853088 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 23:10:16 +0200 Subject: [PATCH 051/188] no-mistakes: apply CI fixes --- .github/workflows/pullrequest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index f5e0d55d0..a28a065ab 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -73,11 +73,11 @@ jobs: loader: Fabric artifact: connect-share-fabric-1.21.1-*.jar - minecraft: 1.20.1 - project: forge-1.20.1 + project: forge-1-20-1 loader: Forge artifact: connect-share-forge-1.20.1-*.jar - minecraft: 1.21.1 - project: neoforge-1.21.1 + project: neoforge-1-21-1 loader: NeoForge artifact: connect-share-neoforge-1.21.1-*.jar From eefdf0e073ed0e32ce538385b13dc76716161513 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 1 Aug 2026 23:32:37 +0200 Subject: [PATCH 052/188] fix(share): close social authorization gaps --- README.md | 3 +- docs/connect-share-testing.md | 12 +++++++ docs/connect-share.md | 14 ++++++-- share/AGENTS.md | 7 ++++ .../connect/share/DirectShareIngress.kt | 22 ++++++++++-- .../connect/share/ShareCoordinator.kt | 6 +++- .../share/admission/AdmissionController.kt | 8 ++++- .../connect/share/friend/FriendStore.kt | 34 +++++++++++++++++-- .../connect/share/ShareCoordinatorTest.kt | 21 ++++++++++++ .../admission/AdmissionControllerTest.kt | 20 +++++++++++ .../connect/share/friend/FriendStoreTest.kt | 25 ++++++++++++-- .../share/fabric/v1_20_1/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../share/fabric/v1_21_1/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../fabric/v1_21_11/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../share/fabric/v26_2/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../share/fabric/ApprovedJoinTracker.kt | 18 ++++++++++ .../share/fabric/FabricDirectShareIngress.kt | 22 ++++++------ .../share/fabric/FabricShareBootstrap.kt | 21 +++++++++++- .../share/fabric/FabricShareBrowser.kt | 33 ++++++++++++------ .../connect/share/fabric/FriendCardIssuer.kt | 12 +++++-- .../share/fabric/FriendRequestServer.kt | 2 ++ .../share/fabric/PersistentDirectIngress.kt | 22 ++++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 3 ++ .../share/fabric/ApprovedJoinTrackerTest.kt | 17 ++++++++++ .../fabric/FabricDirectShareIngressTest.kt | 7 +++- .../share/fabric/FabricShareBrowserTest.kt | 20 +++++++++++ .../share/fabric/FriendCardIssuerTest.kt | 34 +++++++++++++++++++ .../share/fabric/FriendRequestServerTest.kt | 4 +++ 36 files changed, 366 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 07695369a..a84bac983 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ The current implementation provides: - a stable `*.play.minekube.net` address for unmodified Java clients; - signed friend links and temporary world invitations for modded clients; - automatic same-LAN discovery and direct libp2p transport; -- optional internet-direct attempts only when host and guest both opt in; +- direct libp2p friend delivery across LANs from explicitly shared friend links, + plus opt-in internet-direct gameplay attempts; - exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; - explicit support for authenticated and unverified offline-mode guests; and diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 8575685ab..2d1e4a592 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -98,6 +98,11 @@ address, such as a publicly routed host or an explicitly configured network. 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. Copying a friend link is an +explicit disclosure action and may include signed direct candidates. A saved +friend tries fresh mDNS first, then those 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 internet-direct disabled on either peer, confirm the guest does not @@ -115,6 +120,13 @@ self-hosted libp2p relay. 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 diff --git a/docs/connect-share.md b/docs/connect-share.md index 46db3a199..539e5f2b4 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -19,6 +19,12 @@ IP addresses or create a new link for every world. requests and presence themselves are authenticated libp2p traffic and never use Connect as a social relay. +Friend links carry signed direct candidates when the local libp2p 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. 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 @@ -47,9 +53,11 @@ or blocking cannot be bypassed with an old attempt. - 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 is opt-in on both sides because it can reveal public IP - addresses to that friend. Direct LAN addresses, endpoint tokens, invitation - capabilities, and private keys are never shown in the social UI. +- Internet-direct gameplay remains opt-in on both sides. 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. diff --git a/share/AGENTS.md b/share/AGENTS.md index 9c190ad75..23afe60f5 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -99,6 +99,13 @@ redesigned for Kotlin. authentication. Otherwise an offline Prism friend is rejected as "Invalid session" before admission runs. `ONLINE` direct sessions must never silently downgrade. +- Persistent friend cards must retain signed direct candidates and friend + control must try those candidates after mDNS, without ever using Connect as a + social relay. Copying a friend link is the disclosure boundary for those + routes; removal must revoke both admission grants and reciprocal-card proofs. +- Invitation renewal is not complete when only mDNS receives a fresh token. + Every copy action must resolve the current handle invitation so a long-running + share never copies the original expired token. - For no-click friend-request E2E, temporarily enable automatic joins only for the confirmed test friend, send the real libp2p join request, and restore the permission afterwards. Keep machine-specific instance paths and credentials 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 index 1cb010ba1..81e3f8d9b 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt @@ -3,18 +3,34 @@ package com.minekube.connect.share import java.net.SocketAddress class DirectShareHandle( - val invitation: String, + 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 = this.invitation, + invitation: String? = null, lanAvailable: Boolean = this.lanAvailable, internetAvailable: Boolean = this.internetAvailable, close: suspend () -> Unit = this.close, ) = DirectShareHandle( - invitation = invitation, + invitationProvider = invitation?.let { value -> { value } } + ?: invitationProvider, lanAvailable = lanAvailable, internetAvailable = internetAvailable, close = close, 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 index 0e5711200..4c358ec58 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -28,6 +28,7 @@ class ShareCoordinator( ) { private val lifecycleMutex = Mutex() private val mutableState = MutableStateFlow(ShareState.Idle) + @Volatile private var active: ActiveShare? = null val state: StateFlow = mutableState.asStateFlow() @@ -102,7 +103,7 @@ class ShareCoordinator( internetDirectAvailable = acquired.direct?.internetAvailable == true, ) - active = ActiveShare(release) + active = ActiveShare(release, acquired.direct) mutableState.value = sharing Either.Right(sharing) } catch (cancellation: CancellationException) { @@ -162,6 +163,8 @@ class ShareCoordinator( suspend fun worldReplaced(): Either = stop() + fun currentInvitation(): String? = active?.direct?.invitation + private data class AcquiredShare( val target: LocalShareTarget, val connect: ConnectShareHandle?, @@ -170,6 +173,7 @@ class ShareCoordinator( private data class ActiveShare( val release: suspend (ExitCase) -> Unit, + val direct: DirectShareHandle?, ) @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) 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 index 9df7af82e..e8db30569 100644 --- 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 @@ -165,7 +165,13 @@ class AdmissionController( ) } val matches = requests.entries.filter { entry -> - entry.value.pending.identity.directPeerId == peerId + 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() 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 index 72b66deb9..d7bb350f8 100644 --- 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 @@ -68,6 +68,8 @@ data class SavedFriend( val shareId: UUID, val capability: String, val connectAddress: String?, + val internetDirectEnabled: Boolean = false, + val directCandidates: List = emptyList(), val displayName: String, val minecraftUuid: UUID? = null, val permissions: FriendPermissions = FriendPermissions(), @@ -77,7 +79,8 @@ data class SavedFriend( override fun toString(): String = "SavedFriend(peerId=$peerId, publicKey=, " + "shareId=$shareId, capability=, " + - "connectAddress=$connectAddress, displayName=$displayName, " + + "connectAddress=$connectAddress, directCandidates=, " + + "displayName=$displayName, " + "minecraftUuid=$minecraftUuid, permissions=$permissions, " + "relationshipStatus=$relationshipStatus)" } @@ -249,6 +252,8 @@ class FriendStore( shareId = invite.payload.shareId, capability = invite.payload.capability, connectAddress = invite.payload.connectAddress, + internetDirectEnabled = invite.payload.internetDirectEnabled, + directCandidates = invite.payload.directCandidates, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = (existing?.permissions ?: FriendPermissions()) @@ -494,6 +499,11 @@ class FriendStore( val shareId = UUID.fromString(json.requiredString("shareId")) val capability = json.requiredString("capability") val connectAddress = json.optionalString("connectAddress") + val internetDirectEnabled = + json.optionalBoolean("internetDirectEnabled") ?: false + val directCandidates = json.getAsJsonArray("directCandidates") + ?.map { it.asString } + ?: emptyList() val displayName = json.requiredString("displayName") val minecraftUuid = json.optionalString("minecraftUuid") ?.let(UUID::fromString) @@ -501,6 +511,15 @@ class FriendStore( 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") @@ -534,6 +553,8 @@ class FriendStore( shareId = shareId, capability = capability, connectAddress = connectAddress, + internetDirectEnabled = internetDirectEnabled, + directCandidates = directCandidates, displayName = displayName, minecraftUuid = minecraftUuid, permissions = parsedPermissions, @@ -602,6 +623,10 @@ class FriendStore( addProperty("shareId", shareId.toString()) addProperty("capability", capability) connectAddress?.let { addProperty("connectAddress", it) } + addProperty("internetDirectEnabled", internetDirectEnabled) + add("directCandidates", JsonArray().apply { + directCandidates.forEach(::add) + }) addProperty("displayName", displayName) minecraftUuid?.let { addProperty("minecraftUuid", it.toString()) } addProperty("relationshipStatus", relationshipStatus.name) @@ -667,14 +692,19 @@ class FriendStore( 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 = 4 + private const val WIRE_VERSION = 5 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() 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 index 90ec30ea3..daa158288 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -133,6 +133,27 @@ class ShareCoordinatorTest { ) } + @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() 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 index 8556772b2..9a5061413 100644 --- 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 @@ -357,6 +357,26 @@ class AdmissionControllerTest { 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 }, 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 index 91d69bba2..cdd68dca4 100644 --- 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 @@ -59,6 +59,23 @@ class FriendStoreTest { ) } + @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 `confirming an outgoing request promotes it across restarts`() { val store = FriendStore(tempDir) @@ -325,6 +342,8 @@ class FriendStoreTest { private fun signedLink( expiresAt: Instant = NOW.plusSeconds(3_600), + internetDirectEnabled: Boolean = false, + directCandidates: List = emptyList(), ): String { val payload = ShareInvitePayload( wireVersion = ShareInviteCodec.WIRE_VERSION, @@ -332,8 +351,8 @@ class FriendStoreTest { expiresAtEpochMillis = expiresAt.toEpochMilli(), connectAddress = CONNECT_ADDRESS, peerId = PEER_ID, - internetDirectEnabled = false, - directCandidates = emptyList(), + internetDirectEnabled = internetDirectEnabled, + directCandidates = directCandidates, capability = CAPABILITY, ) val unsigned = ShareInviteCodec.unsignedBytes( @@ -383,6 +402,8 @@ class FriendStoreTest { 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/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 index cc1b4b760..c27494d70 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft!!.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index 7ac012a5b..3e7b03c02 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft!!.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index fa4f627ae..8f479f162 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index a86bc984c..032ce83b8 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index 6e405f5e8..fd490811f 100644 --- 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 @@ -34,6 +34,7 @@ class ApprovedJoinTracker( authenticatedMinecraftUuid = (identity as? AdmissionIdentity.Authenticated)?.uuid, ), + directPeerId = identity.directPeerId, approvedAtMillis = now, ) } @@ -67,6 +68,22 @@ class ApprovedJoinTracker( } } + 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) @@ -77,6 +94,7 @@ class ApprovedJoinTracker( private data class TimedProof( val proof: ApprovedJoinProof, + val directPeerId: String?, val approvedAtMillis: Long, ) 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 index dfbfd1d77..c4c79e9ca 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -110,22 +111,23 @@ class FabricDirectShareIngress private constructor( connectAddress = connectAddress, options = options, ) + val currentInvitation = AtomicReference(invitation) node.publish(invitation) val renewalScope = CoroutineScope(SupervisorJob() + renewalDispatcher) val renewalJob = renewalScope.launch { while (isActive) { delay(INVITATION_RENEWAL_MILLIS) try { - node.publish( - invitation( - node = node, - host = host, - shareId = id, - secret = secret, - connectAddress = connectAddress, - options = options, - ), + val renewed = invitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options, ) + node.publish(renewed) + currentInvitation.set(renewed) } catch (cancellation: CancellationException) { throw cancellation } catch (_: RuntimeException) { @@ -134,7 +136,7 @@ class FabricDirectShareIngress private constructor( } val closed = AtomicBoolean() return DirectShareHandle( - invitation = invitation, + invitationProvider = currentInvitation::get, lanAvailable = true, internetAvailable = options.allowInternetDirect && 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 index adc13d1e8..3e819f039 100644 --- 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 @@ -9,6 +9,7 @@ import com.minekube.connect.share.ShareOptions 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 @@ -120,11 +121,25 @@ object FabricShareBootstrap { "${endpointIdentity.endpoint}.play.minekube.net", ) val accessIdentityStore = ShareAccessIdentityStore(dataDirectory) + val directIngressReference = AtomicReference() val friendCardIssuer = FriendCardIssuer( dataDirectory = dataDirectory, displayName = playerDisplayName, connectAddress = { ownConnectAddress.get() }, accessIdentityStore = accessIdentityStore, + 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( @@ -133,6 +148,7 @@ object FabricShareBootstrap { issuer = friendCardIssuer, receiver = friendCardReceiver, friendStore = friendStore, + approvedJoins = approvedJoins, activity = friendActivity, presencePrivacy = { preferences.get().presence }, joinTarget = friendJoinTarget, @@ -176,6 +192,7 @@ object FabricShareBootstrap { val directIngress = PersistentDirectIngress( directPeer.ingress, ) + directIngressReference.set(directIngress) val coordinator = ShareCoordinator( bridge = bridge, ingress = ingress, @@ -199,7 +216,7 @@ object FabricShareBootstrap { options = ShareOptions( gameMode = ShareGameMode.SURVIVAL, allowCheats = false, - allowInternetDirect = false, + allowInternetDirect = true, ), target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, @@ -241,6 +258,7 @@ object FabricShareBootstrap { startShare = coordinator::start, stopShare = coordinator::stop, answerAdmission = admission::answer, + currentInvitation = coordinator::currentInvitation, ) viewModelReference.set(viewModel) val runtime = ConnectShareRuntime( @@ -279,6 +297,7 @@ object FabricShareBootstrap { ?.friend ?.minecraftUuid admission.revokeDirectPeer(peerId, minecraftUuid) + approvedJoins.revokeDirectPeer(peerId, minecraftUuid) }, onRemovalQueued = { scope.launch(Dispatchers.IO) { 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 index f118e17b5..9be929bea 100644 --- 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 @@ -269,16 +269,29 @@ class FabricShareBrowser private constructor( authMode: DirectP2pAuthMode, ): Either = withContext(ioDispatcher) { - val discovered = matchingLanShare(friend) - ?: return@withContext GuestJoinFailure.NoRoute.left() - openDirect( - route = ShareRoute.DIRECT_LAN, - address = discovered.lanAddress, - shareId = friend.shareId.toString(), - capability = friend.capability, - authMode = authMode, - timeout = LAN_TIMEOUT, - )?.right() ?: GuestJoinFailure.NoRoute.left() + 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) { + 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( 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 index 673ca597c..bc714e38c 100644 --- 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 @@ -18,6 +18,11 @@ import java.util.UUID data object FriendCardIssueFailure +data class FriendDirectRoute( + val internetDirectEnabled: Boolean, + val candidates: List, +) + class FriendCardReceiver( private val store: FriendStore, ) { @@ -52,6 +57,7 @@ class FriendCardIssuer( private val displayName: () -> String? = { null }, private val accessIdentityStore: ShareAccessIdentityStore = ShareAccessIdentityStore(dataDirectory), + private val directRoute: suspend () -> FriendDirectRoute? = { null }, private val connectAddress: suspend () -> String?, ) { suspend fun issue( @@ -69,6 +75,7 @@ class FriendCardIssuer( DirectP2pNode( dataDirectory.resolve(IDENTITY_FILE_NAME), ).use { node -> + val route = directRoute() val payload = ShareInvitePayload( wireVersion = ShareInviteCodec.WIRE_VERSION, shareId = access.shareId, @@ -77,8 +84,9 @@ class FriendCardIssuer( .toEpochMilli(), connectAddress = connectAddress(), peerId = node.peerId(), - internetDirectEnabled = false, - directCandidates = emptyList(), + internetDirectEnabled = + route?.internetDirectEnabled == true, + directCandidates = route?.candidates.orEmpty(), capability = access.capability, displayName = normalizedDisplayName, ) 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 index 708cf3483..83714465f 100644 --- 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 @@ -36,6 +36,7 @@ class FriendRequestServer( 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 = {}, @@ -83,6 +84,7 @@ class FriendRequestServer( .getOrNull() ?.minecraftUuid admission.revokeDirectPeer(peerId, minecraftUuid) + approvedJoins?.revokeDirectPeer(peerId, minecraftUuid) if (friendStore.applyRemoteRemoval(peerId)) { notifyRelationshipChanged() } 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 index 0a9e4a3ab..3ca607b8c 100644 --- 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 @@ -11,8 +11,12 @@ 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 @@ -57,6 +61,24 @@ class PersistentDirectIngress( 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, 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 index 75c775e09..5fc38a7ca 100644 --- 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 @@ -124,8 +124,11 @@ class ShareViewModel( 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, 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 index 5802dee98..3cf1ee680 100644 --- 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 @@ -53,6 +53,23 @@ class ApprovedJoinTrackerTest { assertNull(tracker.consume("Robin", PLAYER_UUID)) } + @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)) + } + 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/FabricDirectShareIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt index 33201f914..c35608aca 100644 --- 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 @@ -110,9 +110,10 @@ class FabricDirectShareIngressTest { @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 = { Instant.ofEpochMilli(NOW) }, + now = { currentTime }, shareId = { SHARE_ID }, capability = { CAPABILITY }, displayName = { "World" }, @@ -128,11 +129,15 @@ class FabricDirectShareIngressTest { ), 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() } 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 index 5f65efb48..caaad8476 100644 --- 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 @@ -144,6 +144,24 @@ class FabricShareBrowserTest { 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 ignores LAN metadata signed by a different identity`() = runTest { val node = FakeGuestNode() @@ -385,6 +403,8 @@ class FabricShareBrowserTest { shareId = invitation.payload.shareId, capability = invitation.payload.capability, connectAddress = invitation.payload.connectAddress, + internetDirectEnabled = invitation.payload.internetDirectEnabled, + directCandidates = invitation.payload.directCandidates, displayName = "Robin", ) } 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 index 92eccd392..3fbf9ae27 100644 --- 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 @@ -139,7 +139,41 @@ class FriendCardIssuerTest { ) } + @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/FriendRequestServerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt index 1210f739e..b89caca2c 100644 --- 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 @@ -220,12 +220,15 @@ class FriendRequestServerTest { 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), ) @@ -245,6 +248,7 @@ class FriendRequestServerTest { ) assertTrue(hostStore.all().isEmpty()) assertTrue(hostStore.pendingRemovals().isEmpty()) + assertFalse(approvedJoins.hasProof("bob", PLAYER_UUID)) val afterRemoval = async { admission.request(authenticated) } From efc1ad1e8c87b9c86114a6cefe76f6740d8ae701 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 00:10:12 +0200 Subject: [PATCH 053/188] no-mistakes(review): Closed direct-route, privacy, convergence, and packaging findings --- .github/workflows/connect-share-release.yml | 2 +- .github/workflows/pullrequest.yml | 16 +++-- .../connect/tunnel/p2p/DirectP2pNode.java | 15 +++++ .../tunnel/p2p/DirectP2pNodeRuntime.java | 8 ++- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 8 ++- .../connect/share/friend/FriendControlWire.kt | 12 ++++ .../connect/share/friend/FriendStore.kt | 37 ++++++++-- .../connect/share/friend/FriendStoreTest.kt | 25 ++++++- .../fabric/v1_20_1/ConnectShare12111Client.kt | 2 +- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 13 ++++ .../fabric/v1_21_1/ConnectShare12111Client.kt | 2 +- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 13 ++++ .../v1_21_11/ConnectShare12111Client.kt | 2 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 13 ++++ .../fabric/v26_2/ConnectShare262Client.kt | 2 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 13 ++++ .../share/fabric/FabricDirectPeerRuntime.kt | 7 +- .../share/fabric/FabricDirectShareIngress.kt | 44 +++++++++--- .../share/fabric/FabricShareBootstrap.kt | 14 ++-- .../share/fabric/FabricShareBrowser.kt | 67 +++++++++++++++++++ .../connect/share/fabric/FriendCardIssuer.kt | 15 ++++- .../share/fabric/FriendPairingClient.kt | 1 + .../share/fabric/FriendPresenceMonitor.kt | 2 +- .../share/fabric/FriendRequestServer.kt | 21 ++++-- .../share/fabric/MinecraftStatusProbe.kt | 2 + .../share/fabric/ui/FriendsViewModel.kt | 2 + .../fabric/FabricDirectPeerRuntimeTest.kt | 5 +- .../fabric/FabricDirectShareIngressTest.kt | 15 ++++- .../share/fabric/FabricShareBrowserTest.kt | 63 +++++++++++++++-- .../fabric/FriendPairingDirectE2ETest.kt | 12 +++- .../share/fabric/FriendRequestServerTest.kt | 6 +- 31 files changed, 400 insertions(+), 59 deletions(-) diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml index 287460f94..c81d1193b 100644 --- a/.github/workflows/connect-share-release.yml +++ b/.github/workflows/connect-share-release.yml @@ -65,7 +65,7 @@ jobs: set -euo pipefail mkdir -p dist for minecraft in 1.20.1 1.21.1 1.21.11 26.2; do - project="fabric-${minecraft//./-}" + 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' \ diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index a28a065ab..e35c0cf0f 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -66,18 +66,22 @@ jobs: 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 @@ -105,12 +109,12 @@ jobs: with: name: Connect Share ${{ matrix.loader }} ${{ matrix.minecraft }} path: | - share/${{ matrix.project }}/build/libs/${{ matrix.artifact }} - !share/${{ matrix.project }}/build/libs/*-sources.jar - !share/${{ matrix.project }}/build/libs/*-dev-*.jar - !share/${{ matrix.project }}/build/libs/*-dev-shadow.jar - !share/${{ matrix.project }}/build/libs/*-unshaded.jar - !share/${{ matrix.project }}/build/libs/*-parent-shadow.jar + 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 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 index f13ed7146..50ef7f13f 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -38,6 +38,7 @@ public final class DirectP2pNode implements AutoCloseable { private Method startHost; private Method sign; private Method publish; + private Method publishWithDiscoveryInvitation; private Method inspect; private Method startDiscovery; private Method openProxy; @@ -74,6 +75,10 @@ private void initialize(Path identityFile) { publish = accessible(runtimeClass.getDeclaredMethod( "publish", String.class)); + publishWithDiscoveryInvitation = accessible(runtimeClass.getDeclaredMethod( + "publish", + String.class, + String.class)); inspect = accessible(runtimeClass.getDeclaredMethod( "inspect", String.class, @@ -120,6 +125,16 @@ 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) { 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 index 200a2d1c1..fc73f067a 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -107,6 +107,7 @@ final class DirectP2pNodeRuntime { private DirectP2pHostConfig hostConfig; private DirectP2pHostHandler hostHandler; private volatile String invitation; + private volatile String discoveryInvitation; private JmDNS discovery; private DirectP2pDiscoveryListener discoveryListener; private boolean started; @@ -183,11 +184,16 @@ synchronized byte[] sign(byte[] payload) { } 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(); } @@ -605,7 +611,7 @@ private static String requireInvitation(String value) { } private byte[] encodeInfoResponse() { - String currentInvitation = invitation; + String currentInvitation = discoveryInvitation; DirectP2pHostConfig currentConfig = hostConfig; if (currentInvitation == null || currentConfig == null) { return null; 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 index a389b3ad8..a58f6ab33 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -216,7 +216,9 @@ void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { "Robin's World", false), ignored -> new Socket()); - host.publish("minekube://share/signed-secret-payload"); + host.publish( + "minekube://share/signed-secret-payload", + "minekube://share/signed-lan-payload"); guest = new DirectP2pNode(); DirectP2pDiscoveredShare discovered = guest.inspect( @@ -226,9 +228,9 @@ void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { assertEquals("Robin's World", discovered.displayName()); assertEquals(hostInfo.peerId(), discovered.peerId()); assertEquals( - "minekube://share/signed-secret-payload", + "minekube://share/signed-lan-payload", discovered.invitation()); - assertFalse(discovered.toString().contains("signed-secret-payload")); + assertFalse(discovered.toString().contains("signed-lan-payload")); assertFalse(discovered.toString().contains(hostInfo.lanAddresses().get(0))); } 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 index ecc5229e4..c3e049c65 100644 --- 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 @@ -7,12 +7,14 @@ 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) @@ -122,6 +124,8 @@ object FriendControlWire { 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) } @@ -145,6 +149,10 @@ object FriendControlWire { control.readLong(), control.readLong(), ) + val relationshipId = UUID( + control.readLong(), + control.readLong(), + ) val displayName = control .readString(MAX_DISPLAY_NAME_BYTES) .trim() @@ -154,6 +162,7 @@ object FriendControlWire { control.ensureFinished() FriendControlRequest( requestId = requestId, + relationshipId = relationshipId, displayName = displayName, invitation = invitation, ) @@ -166,6 +175,8 @@ object FriendControlWire { writeVarInt(CONTROL_REMOVAL_PACKET_ID) writeLong(request.operationId.mostSignificantBits) writeLong(request.operationId.leastSignificantBits) + writeLong(request.relationshipId.mostSignificantBits) + writeLong(request.relationshipId.leastSignificantBits) } return output.toByteArray() } @@ -181,6 +192,7 @@ object FriendControlWire { ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) val request = FriendRemovalRequest( UUID(control.readLong(), control.readLong()), + UUID(control.readLong(), control.readLong()), ) control.ensureFinished() request 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 index d7bb350f8..8c8ffd336 100644 --- 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 @@ -67,6 +67,7 @@ data class SavedFriend( val publicKeyBase64: String, val shareId: UUID, val capability: String, + val relationshipId: UUID = UUID.randomUUID(), val connectAddress: String?, val internetDirectEnabled: Boolean = false, val directCandidates: List = emptyList(), @@ -168,12 +169,14 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), ): Either = storeInvitation( invitationUri = invitationUri, displayName = displayName, relationshipStatus = FriendRelationshipStatus.CONFIRMED, now = now, + relationshipId = relationshipId, ) @Synchronized @@ -181,6 +184,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), ): Either = storeInvitation( invitationUri = invitationUri, @@ -188,6 +192,7 @@ class FriendStore( relationshipStatus = FriendRelationshipStatus.CONFIRMED, allowAutomaticJoin = true, now = now, + relationshipId = relationshipId, ) @Synchronized @@ -195,6 +200,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), ): Either = storeInvitation( invitationUri = invitationUri, @@ -202,6 +208,7 @@ class FriendStore( relationshipStatus = FriendRelationshipStatus.PENDING_OUTGOING, now = now, + relationshipId = relationshipId, ) @Synchronized @@ -219,6 +226,7 @@ class FriendStore( relationshipStatus: FriendRelationshipStatus, allowAutomaticJoin: Boolean = false, now: Instant, + relationshipId: UUID, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) .mapLeft(FriendStoreError::InvalidInvitation) @@ -251,6 +259,7 @@ class FriendStore( publicKeyBase64 = publicKey, shareId = invite.payload.shareId, capability = invite.payload.capability, + relationshipId = existing?.relationshipId ?: relationshipId, connectAddress = invite.payload.connectAddress, internetDirectEnabled = invite.payload.internetDirectEnabled, directCandidates = invite.payload.directCandidates, @@ -372,13 +381,16 @@ class FriendStore( } @Synchronized - fun applyRemoteRemoval(peerId: String): Boolean { + fun applyRemoteRemoval( + peerId: String, + relationshipId: UUID, + ): SavedFriend? { val current = read() - if (current.none { it.peerId == peerId }) { - return false - } + val removed = current.firstOrNull { it.peerId == peerId } + ?.takeIf { it.relationshipId == relationshipId } + ?: return null write(data().copy(friends = current.filterNot { it.peerId == peerId })) - return true + return removed } @Synchronized @@ -498,6 +510,9 @@ class FriendStore( 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 connectAddress = json.optionalString("connectAddress") val internetDirectEnabled = json.optionalBoolean("internetDirectEnabled") ?: false @@ -552,6 +567,7 @@ class FriendStore( publicKeyBase64 = publicKey, shareId = shareId, capability = capability, + relationshipId = relationshipId, connectAddress = connectAddress, internetDirectEnabled = internetDirectEnabled, directCandidates = directCandidates, @@ -621,6 +637,7 @@ class FriendStore( addProperty("peerId", peerId) addProperty("publicKey", publicKeyBase64) addProperty("shareId", shareId.toString()) + addProperty("relationshipId", relationshipId.toString()) addProperty("capability", capability) connectAddress?.let { addProperty("connectAddress", it) } addProperty("internetDirectEnabled", internetDirectEnabled) @@ -701,7 +718,7 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" private const val MIN_WIRE_VERSION = 1 - private const val WIRE_VERSION = 5 + private const val WIRE_VERSION = 6 private const val MAX_FRIENDS = 256 private const val MAX_DIRECT_CANDIDATES = 4 private const val MAX_DIRECT_CANDIDATE_LENGTH = 8_192 @@ -733,6 +750,14 @@ class FriendStore( 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( 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 index cdd68dca4..f9d931afc 100644 --- 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 @@ -302,15 +302,36 @@ class FriendStoreTest { 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 - assertTrue(store.applyRemoteRemoval(PEER_ID)) - assertFalse(store.applyRemoteRemoval(PEER_ID)) + 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) 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 index 8daf811e0..1e7014276 100644 --- 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 @@ -98,7 +98,7 @@ class ConnectShare1201Runtime( val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index ad23de8a3..b041982c4 100644 --- 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 @@ -873,6 +873,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft!!.user.name, invitation = senderCard, ), 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 index d24a6ae3c..d8fe5f171 100644 --- 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 @@ -98,7 +98,7 @@ class ConnectShare1211Runtime( val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index 151c75af9..6015ac10f 100644 --- 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 @@ -867,6 +867,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft!!.user.name, invitation = senderCard, ), 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 index 7476d1671..cd589871c 100644 --- 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 @@ -114,7 +114,7 @@ class ConnectShare12111Client : ClientModInitializer { val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index 60fd509ae..d14b016cc 100644 --- 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 @@ -870,6 +870,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft.user.name, invitation = senderCard, ), 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 index 43492fc96..78dccc222 100644 --- 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 @@ -114,7 +114,7 @@ class ConnectShare262Client : ClientModInitializer { val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index 78bb37090..bed2082c0 100644 --- 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 @@ -870,6 +870,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft.user.name, invitation = senderCard, ), 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 index 2e625f0f5..e62035cc5 100644 --- 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 @@ -83,8 +83,11 @@ private class CoreFabricDirectPeerNode( override fun sign(payload: ByteArray): ByteArray = node.sign(payload) - override fun publish(invitation: String) { - node.publish(invitation) + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) } override fun openProxy( 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 index c4c79e9ca..245245034 100644 --- 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 @@ -103,7 +103,7 @@ class FabricDirectShareIngress private constructor( } else { emptyList() } - val invitation = invitation( + val invitation = createInvitation( node = node, host = host, shareId = id, @@ -112,13 +112,23 @@ class FabricDirectShareIngress private constructor( options = options, ) val currentInvitation = AtomicReference(invitation) - node.publish(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 = invitation( + val renewed = createInvitation( node = node, host = host, shareId = id, @@ -126,7 +136,19 @@ class FabricDirectShareIngress private constructor( connectAddress = connectAddress, options = options, ) - node.publish(renewed) + 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 @@ -165,7 +187,7 @@ class FabricDirectShareIngress private constructor( } } - private fun invitation( + private fun createInvitation( node: FabricDirectNode, host: DirectP2pHostInfo, shareId: UUID, @@ -278,7 +300,10 @@ internal interface FabricDirectNode : AutoCloseable { fun sign(payload: ByteArray): ByteArray - fun publish(invitation: String) + fun publish( + invitation: String, + discoveryInvitation: String, + ) } private class CoreFabricDirectNode( @@ -291,8 +316,11 @@ private class CoreFabricDirectNode( override fun sign(payload: ByteArray): ByteArray = node.sign(payload) - override fun publish(invitation: String) { - node.publish(invitation) + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) } override fun close() { 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 index 3e819f039..66758fcac 100644 --- 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 @@ -216,7 +216,7 @@ object FabricShareBootstrap { options = ShareOptions( gameMode = ShareGameMode.SURVIVAL, allowCheats = false, - allowInternetDirect = true, + allowInternetDirect = false, ), target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, @@ -281,11 +281,13 @@ object FabricShareBootstrap { ) }, ifRight = { target -> - friendRequestClient.remove( - target, - com.minekube.connect.share.friend - .FriendRemovalRequest(removal.operationId), - ) + friendRequestClient.remove( + target, + com.minekube.connect.share.friend.FriendRemovalRequest( + operationId = removal.operationId, + relationshipId = removal.friend.relationshipId, + ), + ) }, ) } 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 index 9be929bea..c80103144 100644 --- 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 @@ -248,6 +248,27 @@ class FabricShareBrowser private constructor( } else { reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) } + if (friend.internetDirectEnabled) { + 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, @@ -314,6 +335,52 @@ class FabricShareBrowser private constructor( } } + 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) { + 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() 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 index bc714e38c..337e371de 100644 --- 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 @@ -36,12 +36,23 @@ class FriendCardReceiver( displayName: String, authenticatedMinecraftUuid: UUID?, allowAutomaticJoin: Boolean = false, + relationshipId: UUID? = null, now: Instant = Instant.now(), ): Either = (if (allowAutomaticJoin) { - store.acceptAndAllowJoin(invitation, displayName, now) + store.acceptAndAllowJoin( + invitation, + displayName, + now, + relationshipId ?: UUID.randomUUID(), + ) } else { - store.accept(invitation, displayName, now) + store.accept( + invitation, + displayName, + now, + relationshipId ?: UUID.randomUUID(), + ) }).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> store.linkMinecraftIdentity( 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 index 9a54ef96e..3212ddc10 100644 --- 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 @@ -77,6 +77,7 @@ class FriendPairingClient( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = pending.relationshipId, displayName = senderDisplayName, invitation = senderCard, ), 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 index ccc3ee8f3..a67841339 100644 --- 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 @@ -82,7 +82,7 @@ class FriendPresenceMonitor private constructor( description = directPresence?.description, notifyWhenOnline = friend.permissions.notifyWhenOnline, - route = directPresence?.let { ShareRoute.DIRECT_LAN }, + route = directPresence?.route ?: ShareRoute.DIRECT_LAN, ) } mutableState.value = results.toMap() 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 index 83714465f..dc27a2d6d 100644 --- 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 @@ -80,12 +80,19 @@ class FriendRequestServer( ) { FriendControlResponse.Invalid } else { - val minecraftUuid = friendStore.relationship(peerId) - .getOrNull() - ?.minecraftUuid - admission.revokeDirectPeer(peerId, minecraftUuid) - approvedJoins?.revokeDirectPeer(peerId, minecraftUuid) - if (friendStore.applyRemoteRemoval(peerId)) { + val removed = friendStore.applyRemoteRemoval( + peerId, + request.relationshipId, + ) + if (removed != null) { + admission.revokeDirectPeer( + peerId, + removed.minecraftUuid, + ) + approvedJoins?.revokeDirectPeer( + peerId, + removed.minecraftUuid, + ) notifyRelationshipChanged() } FriendControlResponse.Removed @@ -238,6 +245,7 @@ class FriendRequestServer( invitation = request.invitation, displayName = request.displayName, authenticatedMinecraftUuid = null, + relationshipId = request.relationshipId, now = instant, ) if (accepted.isLeft()) { @@ -266,6 +274,7 @@ class FriendRequestServer( invitation = request.invitation, displayName = request.displayName, authenticatedMinecraftUuid = null, + relationshipId = request.relationshipId, now = instant, ) if (received.isLeft()) { 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 index 522b18fba..30457c195 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -17,6 +18,7 @@ import kotlinx.coroutines.withContext data class ServerPresence( val description: String, + val route: ShareRoute? = null, ) sealed interface StatusProbeError { 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 index 10b20d92d..35c8565d2 100644 --- 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 @@ -48,6 +48,7 @@ data class FriendSummary( data class OutgoingFriendRequestSummary( val peerId: String, val displayName: String, + val relationshipId: UUID = UUID.randomUUID(), ) data class IncomingFriendRequestSummary( @@ -343,6 +344,7 @@ class FriendsViewModel( OutgoingFriendRequestSummary( peerId = it.peerId, displayName = it.displayName, + relationshipId = it.relationshipId, ) }, incomingRequests = incomingRequests, 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 index 9db41151c..c12461304 100644 --- 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 @@ -132,7 +132,10 @@ class FabricDirectPeerRuntimeTest { sign() } - override fun publish(invitation: String) { + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { publishes++ } 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 index c35608aca..3c0472abd 100644 --- 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 @@ -67,6 +67,14 @@ class FabricDirectShareIngressTest { 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() @@ -219,6 +227,7 @@ class FabricDirectShareIngressTest { ), ) var published: String? = null + var publishedDiscoveryInvitation: String? = null val publishedInvitations = mutableListOf() var closed = false @@ -234,11 +243,15 @@ class FabricDirectShareIngressTest { sign() } - override fun publish(invitation: String) { + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { if (failPublish) { error("publish failed") } published = invitation + publishedDiscoveryInvitation = discoveryInvitation publishedInvitations += invitation } 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 index caaad8476..eecf846ed 100644 --- 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 @@ -183,22 +183,24 @@ class FabricShareBrowserTest { authMode = DirectP2pAuthMode.OFFLINE, ) - assertIs>(result) - assertTrue(node.openedAddresses.isEmpty()) + 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: using Connect fallback", + "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() + val node = FakeGuestNode(failDirect = true) val browser = browser(node) val friend = savedFriend(invitation()) @@ -212,7 +214,33 @@ class FabricShareBrowserTest { GuestJoinFailure.EndpointConflict, result.leftOrNull(), ) - assertTrue(node.openedAddresses.isEmpty()) + 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() } @@ -249,6 +277,31 @@ class FabricShareBrowserTest { 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() 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 index e5c6bb5fd..0634266a4 100644 --- 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 @@ -280,7 +280,10 @@ class FriendPairingDirectE2ETest { assertTrue( requestClient.remove( removalTarget, - FriendRemovalRequest(removal.operationId), + FriendRemovalRequest( + operationId = removal.operationId, + relationshipId = removal.friend.relationshipId, + ), ).isRight(), ) senderStore.acknowledgeRemoval(removal.operationId) @@ -312,8 +315,11 @@ class FriendPairingDirectE2ETest { override fun sign(payload: ByteArray): ByteArray = node.sign(payload) - override fun publish(invitation: String) { - node.publish(invitation) + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) } override fun close() { 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 index b89caca2c..933f65017 100644 --- 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 @@ -236,7 +236,11 @@ class FriendRequestServerTest { ingress = Ingress.DIRECT_LAN, directPeerId = senderPeerId, ) - val removal = FriendRemovalRequest(UUID.randomUUID()) + val removal = FriendRemovalRequest( + operationId = UUID.randomUUID(), + relationshipId = hostStore.relationship(senderPeerId) + .getOrNull()!!.relationshipId, + ) assertEquals( FriendControlResponse.Removed, From 69dee828df45e70e44a9776f381a2777793595df Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 00:47:22 +0200 Subject: [PATCH 054/188] no-mistakes(document): Updated Share docs and cleared lint --- README.md | 6 +++--- .../specs/2026-07-30-connect-share-mod-design.md | 5 +++++ .../2026-07-30-connect-share-pasted-lan-invite-design.md | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a84bac983..a8c9125a4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Please refer to https://connect.minekube.com for more documentation. ## Connect Share mod -Connect Share is an in-development client-side Fabric, Forge, and NeoForge mod. +Connect Share is a client-side Fabric, Forge, and NeoForge mod. It supports Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and NeoForge 1.21.1. It shares a singleplayer world through Minekube Connect or directly between two modded clients without exposing Minecraft's listener to @@ -33,8 +33,8 @@ The current implementation provides: - a stable `*.play.minekube.net` address for unmodified Java clients; - signed friend links and temporary world invitations for modded clients; - automatic same-LAN discovery and direct libp2p transport; -- direct libp2p friend delivery across LANs from explicitly shared friend links, - plus opt-in internet-direct gameplay attempts; +- direct libp2p friend delivery from explicitly shared friend links when a + direct route exists, plus opt-in internet-direct gameplay attempts; - exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; - explicit support for authenticated and unverified offline-mode guests; and 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 index 2c0491152..fa40efc16 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -4,6 +4,11 @@ **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 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 index 8a393008a..3b4542f8a 100644 --- 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 @@ -4,6 +4,10 @@ **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 From 42d449811a88755fa79876ad045e53def0968c25 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 01:34:19 +0200 Subject: [PATCH 055/188] no-mistakes(review): Fix Share consent, convergence, and wire compatibility --- .../connect/share/friend/FriendControlWire.kt | 55 +++++++++++++- .../connect/share/friend/FriendStore.kt | 65 ++++++++++++---- .../share/friend/FriendControlWireTest.kt | 68 +++++++++++++++++ .../connect/share/friend/FriendStoreTest.kt | 75 +++++++++++++++++++ .../fabric/v1_20_1/FriendCardNetworking.kt | 8 +- .../share/fabric/v1_20_1/FriendCardPayload.kt | 8 +- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 23 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v1_20_1/FriendCardPayloadTest.kt | 11 +-- .../fabric/v1_21_1/FriendCardNetworking.kt | 6 +- .../share/fabric/v1_21_1/FriendCardPayload.kt | 6 +- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 21 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v1_21_1/FriendCardPayloadTest.kt | 11 +-- .../fabric/v1_21_11/FriendCardNetworking.kt | 6 +- .../fabric/v1_21_11/FriendCardPayload.kt | 6 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 21 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v1_21_11/FriendCardPayloadTest.kt | 11 +-- .../fabric/v26_2/FriendCardNetworking.kt | 6 +- .../share/fabric/v26_2/FriendCardPayload.kt | 6 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 21 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v26_2/FriendCardPayloadTest.kt | 11 +-- .../share/fabric/ConnectShareClient.kt | 6 +- .../share/fabric/FabricDirectShareIngress.kt | 3 +- .../share/fabric/FabricShareBootstrap.kt | 30 ++++++-- .../share/fabric/FabricShareBrowser.kt | 6 +- .../share/fabric/FriendCardExchangeConsent.kt | 7 +- .../connect/share/fabric/FriendCardIssuer.kt | 8 +- .../share/fabric/FriendPairingClient.kt | 1 + .../share/fabric/FriendRequestServer.kt | 25 +++---- .../share/fabric/ui/FriendsViewModel.kt | 17 +++++ .../share/fabric/FabricShareBootstrapTest.kt | 8 ++ .../share/fabric/FabricShareBrowserTest.kt | 19 +++++ .../fabric/FriendCardExchangeConsentTest.kt | 9 +++ .../v1_20_1/ForgeFriendCardNetworking.kt | 22 +++++- .../v1_21_1/NeoForgeFriendCardNetworking.kt | 16 +++- 42 files changed, 547 insertions(+), 99 deletions(-) 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 index c3e049c65..4ed1992e3 100644 --- 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 @@ -142,7 +142,7 @@ object FriendControlWire { if (bytes.size > MAX_REQUEST_BYTES) { return FriendControlDecode.Invalid } - return decode(bytes) { + val current = decode(bytes) { val control = readPacket() ensure(control.readVarInt() == CONTROL_REQUEST_PACKET_ID) val requestId = UUID( @@ -167,6 +167,37 @@ object FriendControlWire { 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 { @@ -187,7 +218,7 @@ object FriendControlWire { if (bytes.size > MAX_REQUEST_BYTES) { return FriendControlDecode.Invalid } - return decode(bytes) { + val current = decode(bytes) { val control = readPacket() ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) val request = FriendRemovalRequest( @@ -197,6 +228,26 @@ object FriendControlWire { 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 = 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 index 8c8ffd336..04c41ab81 100644 --- 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 @@ -68,9 +68,11 @@ data class SavedFriend( 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(), @@ -169,7 +171,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), - relationshipId: UUID = UUID.randomUUID(), + relationshipId: UUID? = null, ): Either = storeInvitation( invitationUri = invitationUri, @@ -184,7 +186,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), - relationshipId: UUID = UUID.randomUUID(), + relationshipId: UUID? = null, ): Either = storeInvitation( invitationUri = invitationUri, @@ -226,7 +228,7 @@ class FriendStore( relationshipStatus: FriendRelationshipStatus, allowAutomaticJoin: Boolean = false, now: Instant, - relationshipId: UUID, + relationshipId: UUID?, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) .mapLeft(FriendStoreError::InvalidInvitation) @@ -259,10 +261,22 @@ class FriendStore( publicKeyBase64 = publicKey, shareId = invite.payload.shareId, capability = invite.payload.capability, - relationshipId = existing?.relationshipId ?: relationshipId, + 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 = existing?.internetDirectGuestOptIn == true, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = (existing?.permissions ?: FriendPermissions()) @@ -310,6 +324,14 @@ class FriendStore( friend.copy(permissions = permissions) } + @Synchronized + fun setInternetDirectGuestOptIn( + peerId: String, + enabled: Boolean, + ): Either = update(peerId) { friend -> + friend.copy(internetDirectGuestOptIn = enabled) + } + @Synchronized fun linkMinecraftIdentity( peerId: String, @@ -387,7 +409,9 @@ class FriendStore( ): SavedFriend? { val current = read() val removed = current.firstOrNull { it.peerId == peerId } - ?.takeIf { it.relationshipId == relationshipId } + ?.takeIf { + it.relationshipIdKnown && it.relationshipId == relationshipId + } ?: return null write(data().copy(friends = current.filterNot { it.peerId == peerId })) return removed @@ -441,7 +465,7 @@ class FriendStore( val entries = root.getAsJsonArray("friends") ?: throw IOException("Friends file is missing friends") val friends = entries.map { element -> - parseFriend(element.asJsonObject) + parseFriend(element.asJsonObject, version) } if (friends.size > MAX_FRIENDS) { throw IOException("Friends file contains too many entries") @@ -451,7 +475,7 @@ class FriendStore( } val removals = if (version >= 2) { root.getAsJsonArray("pendingRemovals") - ?.map { element -> parseRemoval(element.asJsonObject) } + ?.map { element -> parseRemoval(element.asJsonObject, version) } ?: emptyList() } else { emptyList() @@ -479,13 +503,15 @@ class FriendStore( } } - private fun parseRemoval(json: JsonObject): PendingFriendRemoval = + private fun parseRemoval( + json: JsonObject, + version: Int, + ): PendingFriendRemoval = PendingFriendRemoval( operationId = UUID.fromString(json.requiredString("operationId")), - friend = parseFriend( - json.getAsJsonObject("friend") - ?: throw IOException("Removal is missing friend"), - ), + 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"), @@ -505,7 +531,10 @@ class FriendStore( ), ) - private fun parseFriend(json: JsonObject): SavedFriend { + 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")) @@ -513,12 +542,16 @@ class FriendStore( 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) @@ -568,9 +601,11 @@ class FriendStore( shareId = shareId, capability = capability, relationshipId = relationshipId, + relationshipIdKnown = relationshipIdKnown, connectAddress = connectAddress, internetDirectEnabled = internetDirectEnabled, directCandidates = directCandidates, + internetDirectGuestOptIn = internetDirectGuestOptIn, displayName = displayName, minecraftUuid = minecraftUuid, permissions = parsedPermissions, @@ -638,12 +673,14 @@ class FriendStore( 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) @@ -718,7 +755,7 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" private const val MIN_WIRE_VERSION = 1 - private const val WIRE_VERSION = 6 + 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 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 index ea1190c1f..22846f7d1 100644 --- 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 @@ -1,5 +1,6 @@ package com.minekube.connect.share.friend +import java.io.ByteArrayOutputStream import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -27,6 +28,22 @@ class FriendControlWireTest { 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( @@ -117,6 +134,16 @@ class FriendControlWireTest { ) } + @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( @@ -142,5 +169,46 @@ class FriendControlWireTest { 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 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 index f9d931afc..180b839aa 100644 --- 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 @@ -76,6 +76,21 @@ class FriendStoreTest { 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 `confirming an outgoing request promotes it across restarts`() { val store = FriendStore(tempDir) @@ -113,6 +128,66 @@ class FriendStoreTest { 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) 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 index 3f2aea4ff..46ac46f6f 100644 --- 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 @@ -22,8 +22,8 @@ object FriendCardNetworking { ServerPlayNetworking.registerGlobalReceiver( FriendCardChannels.CARD, ) { server, player, _, buffer, _ -> - val invitation = runCatching { - buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS) + val payload = runCatching { + FriendCardCodec.decode(buffer) }.getOrNull() ?: return@registerGlobalReceiver server.execute { val proof = approvedJoins.consume( @@ -31,10 +31,11 @@ object FriendCardNetworking { player.uuid, ) ?: return@execute receiver.receive( - invitation = invitation, + invitation = payload.invitation, displayName = player.gameProfile.name, authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -71,6 +72,7 @@ object FriendCardNetworking { 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 index 5824e04dc..84f384b08 100644 --- 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 @@ -2,9 +2,11 @@ package com.minekube.connect.share.fabric.v1_20_1 import net.minecraft.resources.ResourceLocation import net.minecraft.network.FriendlyByteBuf +import java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) { companion object { val CODEC = FriendCardCodec @@ -18,10 +20,14 @@ data object FriendCardRequestPayload { 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(buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS)) + FriendCardPayload( + invitation = buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), + ) } object FriendCardRequestCodec { 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 index b041982c4..efd89e3da 100644 --- 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 @@ -576,7 +576,7 @@ class ShareJoinScreen( .withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -592,10 +592,22 @@ class ShareJoinScreen( friend.permissions.canSeeMyWorlds, ), ) + val guestInternetDirect = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable( + "connect_share.friends.internet_direct", + ), + friend.internetDirectGuestOptIn, + ), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154), + centered(Component.literal(message), 176), ) } } @@ -609,6 +621,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -913,6 +929,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 7e8e41b0d..d88fb1ba5 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index 3e71098d5..2387e836e 100644 --- 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 @@ -42,6 +42,7 @@ object FriendCardNetworking { authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -82,7 +83,10 @@ object FriendCardNetworking { ) ) { ClientPlayNetworking.send( - FriendCardPayload(invitation), + 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 index 19d094f6e..deccf15b4 100644 --- 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 @@ -4,9 +4,11 @@ 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 java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -28,10 +30,12 @@ data class FriendCardPayload( payload.invitation, MAX_CARD_CHARS, ) + buffer.writeUUID(payload.relationshipId) }, { buffer -> FriendCardPayload( - buffer.readUtf(MAX_CARD_CHARS), + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), ) }, ) 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 index 6015ac10f..060a8c690 100644 --- 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 @@ -572,7 +572,7 @@ class ShareJoinScreen( .withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -586,10 +586,20 @@ class ShareJoinScreen( .selected(friend.permissions.canSeeMyWorlds) .build(), ) + val guestInternetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable( + "connect_share.friends.internet_direct", + ), + font, + ).pos(width / 2 - 155, 126) + .selected(friend.internetDirectGuestOptIn) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154), + centered(Component.literal(message), 176), ) } } @@ -603,6 +613,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -907,6 +921,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 7fdf17982..ac78d8857 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index 294da7970..ddc97a07d 100644 --- 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 @@ -42,6 +42,7 @@ object FriendCardNetworking { authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -82,7 +83,10 @@ object FriendCardNetworking { ) ) { ClientPlayNetworking.send( - FriendCardPayload(invitation), + 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 index a2a74ea40..f0b67c81d 100644 --- 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 @@ -4,9 +4,11 @@ import net.minecraft.network.FriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload import net.minecraft.resources.Identifier +import java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -28,10 +30,12 @@ data class FriendCardPayload( payload.invitation, MAX_CARD_CHARS, ) + buffer.writeUUID(payload.relationshipId) }, { buffer -> FriendCardPayload( - buffer.readUtf(MAX_CARD_CHARS), + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), ) }, ) 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 index d14b016cc..ae8d1b7ea 100644 --- 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 @@ -574,7 +574,7 @@ class ShareJoinScreen( ).withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -588,10 +588,20 @@ class ShareJoinScreen( .selected(friend.permissions.canSeeMyWorlds) .build(), ) + val guestInternetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable( + "connect_share.friends.internet_direct", + ), + font, + ).pos(width / 2 - 155, 126) + .selected(friend.internetDirectGuestOptIn) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154) + centered(Component.literal(message), 176) .setMaxWidth(CONTENT_WIDTH), ) } @@ -606,6 +616,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -910,6 +924,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 79161088e..629b6fd94 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index ad5464aa4..db7a3de3c 100644 --- 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 @@ -42,6 +42,7 @@ object FriendCardNetworking { authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -82,7 +83,10 @@ object FriendCardNetworking { ) ) { ClientPlayNetworking.send( - FriendCardPayload(invitation), + 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 index 54f6f1350..99fd5056b 100644 --- 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 @@ -4,9 +4,11 @@ import net.minecraft.network.FriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload import net.minecraft.resources.Identifier +import java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -28,10 +30,12 @@ data class FriendCardPayload( payload.invitation, MAX_CARD_CHARS, ) + buffer.writeUUID(payload.relationshipId) }, { buffer -> FriendCardPayload( - buffer.readUtf(MAX_CARD_CHARS), + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), ) }, ) 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 index bed2082c0..bb7a34451 100644 --- 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 @@ -574,7 +574,7 @@ class ShareJoinScreen( ).withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -588,10 +588,20 @@ class ShareJoinScreen( .selected(friend.permissions.canSeeMyWorlds) .build(), ) + val guestInternetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable( + "connect_share.friends.internet_direct", + ), + font, + ).pos(width / 2 - 155, 126) + .selected(friend.internetDirectGuestOptIn) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154) + centered(Component.literal(message), 176) .setMaxWidth(CONTENT_WIDTH), ) } @@ -606,6 +616,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -910,6 +924,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 90b026376..71dc515a2 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index 7c8e7e5eb..2db152629 100644 --- 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 @@ -132,7 +132,11 @@ object ConnectShareClient { @JvmStatic fun armFriendCardExchange(peerId: String) { - friendCardConsent.arm(peerId) + installation?.friendsViewModel + ?.relationshipId(peerId) + ?.let { relationshipId -> + friendCardConsent.arm(peerId, relationshipId) + } } @JvmStatic 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 index 245245034..7dcf9058a 100644 --- 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 @@ -47,10 +47,11 @@ class FabricDirectShareIngress private constructor( accessIdentityStore: ShareAccessIdentityStore = ShareAccessIdentityStore(dataDirectory), displayName: () -> String, + identityFile: Path = dataDirectory.resolve(IDENTITY_FILE_NAME), ) : this( nodeFactory = { CoreFabricDirectNode( - DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + DirectP2pNode(identityFile), ) }, now = Instant::now, 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 index 66758fcac..fb90a2c12 100644 --- 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 @@ -122,11 +122,15 @@ object FabricShareBootstrap { ) 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() @@ -192,7 +196,15 @@ object FabricShareBootstrap { val directIngress = PersistentDirectIngress( directPeer.ingress, ) - directIngressReference.set(directIngress) + val socialIngress = PersistentDirectIngress( + FabricDirectShareIngress( + dataDirectory = dataDirectory, + accessIdentityStore = accessIdentityStore, + displayName = worldDisplayName, + identityFile = socialIdentityFile, + ), + ) + directIngressReference.set(socialIngress) val coordinator = ShareCoordinator( bridge = bridge, ingress = ingress, @@ -212,12 +224,8 @@ object FabricShareBootstrap { startedControlPlane.start() val startedDirectControlPlane = DirectControlPlane( scope = scope, - ingress = directIngress, - options = ShareOptions( - gameMode = ShareGameMode.SURVIVAL, - allowCheats = false, - allowInternetDirect = false, - ), + ingress = socialIngress, + options = socialControlOptions(), target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, failureReporter = logger::warn, @@ -400,6 +408,12 @@ object FabricShareBootstrap { ).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)}" @@ -418,6 +432,8 @@ object FabricShareBootstrap { 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( 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 index c80103144..70f77ea59 100644 --- 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 @@ -248,7 +248,7 @@ class FabricShareBrowser private constructor( } else { reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) } - if (friend.internetDirectEnabled) { + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { var attempted = false for (address in friend.directCandidates) { attempted = true @@ -300,7 +300,7 @@ class FabricShareBrowser private constructor( timeout = LAN_TIMEOUT, )?.let { return@withContext it.right() } } - if (friend.internetDirectEnabled) { + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { for (address in friend.directCandidates) { openDirect( route = ShareRoute.DIRECT_INTERNET, @@ -358,7 +358,7 @@ class FabricShareBrowser private constructor( } } } - if (friend.internetDirectEnabled) { + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { for (address in friend.directCandidates) { val direct = openDirect( route = ShareRoute.DIRECT_INTERNET, 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 index 8e9eb9da3..26224c86b 100644 --- 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 @@ -1,7 +1,10 @@ package com.minekube.connect.share.fabric +import java.util.UUID + data class FriendCardExchangeProof( val peerId: String, + val relationshipId: UUID, ) class FriendCardExchangeConsent( @@ -10,10 +13,10 @@ class FriendCardExchangeConsent( private var armed: TimedExchange? = null @Synchronized - fun arm(peerId: String) { + fun arm(peerId: String, relationshipId: UUID = UUID.randomUUID()) { require(peerId.isNotBlank()) armed = TimedExchange( - proof = FriendCardExchangeProof(peerId), + proof = FriendCardExchangeProof(peerId, relationshipId), armedAtMillis = nowMillis(), ) } 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 index 337e371de..5fd6eb965 100644 --- 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 @@ -44,14 +44,14 @@ class FriendCardReceiver( invitation, displayName, now, - relationshipId ?: UUID.randomUUID(), + relationshipId, ) } else { store.accept( invitation, displayName, now, - relationshipId ?: UUID.randomUUID(), + relationshipId, ) }).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> @@ -69,6 +69,8 @@ class FriendCardIssuer( 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( @@ -84,7 +86,7 @@ class FriendCardIssuer( Either.catch { val access = accessIdentityStore.currentOrCreate() DirectP2pNode( - dataDirectory.resolve(IDENTITY_FILE_NAME), + identityFile, ).use { node -> val route = directRoute() val payload = ShareInvitePayload( 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 index 3212ddc10..44179d314 100644 --- 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 @@ -87,6 +87,7 @@ class FriendPairingClient( 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/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt index dc27a2d6d..f7096b43a 100644 --- 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 @@ -237,22 +237,17 @@ class FriendRequestServer( if (existing.publicKeyBase64 != senderKey) { return FriendControlResponse.Invalid } - if ( - existing.relationshipStatus == - FriendRelationshipStatus.PENDING_OUTGOING - ) { - val accepted = receiver.receive( - invitation = request.invitation, - displayName = request.displayName, - authenticatedMinecraftUuid = null, - relationshipId = request.relationshipId, - now = instant, - ) - if (accepted.isLeft()) { - return FriendControlResponse.Invalid - } - notifyRelationshipChanged() + 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) } 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 index 35c8565d2..3782d334c 100644 --- 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 @@ -35,6 +35,7 @@ data class FriendSummary( 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, @@ -136,6 +137,21 @@ class FriendsViewModel( ) } + fun updateInternetDirectGuestOptIn( + peerId: String, + enabled: Boolean, + ) { + store.setInternetDirectGuestOptIn(peerId, enabled).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { refresh() }, + ) + } + + internal fun relationshipId(peerId: String): UUID? = + store.relationship(peerId).getOrNull()?.relationshipId + fun remove(peerId: String): Boolean = Either.catch { store.remove(peerId) @@ -376,6 +392,7 @@ class FriendsViewModel( displayName = displayName, connectAvailable = connectAddress != null, permissions = permissions, + internetDirectGuestOptIn = internetDirectGuestOptIn, onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, onlineViaConnect = remote?.route == ShareRoute.CONNECT, worldName = remote?.description, 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 index 04761ce0e..f65afe4c2 100644 --- 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 @@ -2,6 +2,7 @@ package com.minekube.connect.share.fabric import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class FabricShareBootstrapTest { @Test @@ -17,4 +18,11 @@ class FabricShareBootstrapTest { ).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 index eecf846ed..4bc271b9d 100644 --- 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 @@ -162,6 +162,24 @@ class FabricShareBrowserTest { 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() @@ -458,6 +476,7 @@ class FabricShareBrowserTest { connectAddress = invitation.payload.connectAddress, internetDirectEnabled = invitation.payload.internetDirectEnabled, directCandidates = invitation.payload.directCandidates, + internetDirectGuestOptIn = true, displayName = "Robin", ) } 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 index d177e8b90..f9b60ab67 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -34,6 +35,14 @@ class FriendCardExchangeConsentTest { 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( 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 index 2048c9670..c8575f282 100644 --- 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 @@ -18,6 +18,7 @@ import net.minecraftforge.network.NetworkDirection import net.minecraftforge.network.NetworkRegistry import net.minecraftforge.network.PacketDistributor import net.minecraftforge.network.simple.SimpleChannel +import java.util.UUID object ForgeFriendCardNetworking { private const val PROTOCOL = "1" @@ -49,8 +50,16 @@ object ForgeFriendCardNetworking { 0, NetworkDirection.PLAY_TO_SERVER, ) - .encoder { message, buffer -> buffer.writeUtf(message.invitation, MAX_CARD_CHARS) } - .decoder { buffer -> FriendCardMessage(buffer.readUtf(MAX_CARD_CHARS)) } + .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 @@ -64,6 +73,7 @@ object ForgeFriendCardNetworking { displayName = player.gameProfile.name, authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = message.relationshipId, ) } } @@ -84,7 +94,12 @@ object ForgeFriendCardNetworking { handlers.issuer.issue().getOrNull()?.let { invitation -> Minecraft.getInstance().execute { if (Minecraft.getInstance().connection != null) { - channel.sendToServer(FriendCardMessage(invitation)) + channel.sendToServer( + FriendCardMessage( + invitation, + exchange.relationshipId, + ), + ) handlers.scope.launch(Dispatchers.IO) { handlers.receiver.confirmOutgoing(exchange.peerId) } @@ -115,6 +130,7 @@ object ForgeFriendCardNetworking { private data class FriendCardMessage( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) private data object FriendCardRequestMessage 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 index 5a2de7f24..aeb13b987 100644 --- 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 @@ -16,6 +16,7 @@ import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import net.neoforged.neoforge.network.PacketDistributor import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent +import java.util.UUID object NeoForgeFriendCardNetworking { private const val PROTOCOL = "1" @@ -36,7 +37,10 @@ object NeoForgeFriendCardNetworking { Minecraft.getInstance().execute { if (Minecraft.getInstance().connection != null) { PacketDistributor.sendToServer( - FriendCardPayload(invitation), + FriendCardPayload( + invitation, + exchange.relationshipId, + ), ) handlers.scope.launch(Dispatchers.IO) { handlers.receiver.confirmOutgoing(exchange.peerId) @@ -63,6 +67,7 @@ object NeoForgeFriendCardNetworking { displayName = player.gameProfile.name, authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -97,6 +102,7 @@ object NeoForgeFriendCardNetworking { private data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -113,8 +119,14 @@ private data class FriendCardPayload( CustomPacketPayload.codec( { payload, buffer -> buffer.writeUtf(payload.invitation, MAX_CARD_CHARS) + buffer.writeUUID(payload.relationshipId) + }, + { buffer -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + buffer.readUUID(), + ) }, - { buffer -> FriendCardPayload(buffer.readUtf(MAX_CARD_CHARS)) }, ) } } From 425bf4b4abbc3b0348281107f549437bbb58eb7c Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 02:14:07 +0200 Subject: [PATCH 056/188] no-mistakes(document): Updated Share docs and normalized imports --- docs/connect-share-testing.md | 26 ++++++++++++------ docs/connect-share.md | 27 ++++++++++++------- .../share/fabric/v1_20_1/FriendCardPayload.kt | 4 +-- .../share/fabric/v1_21_1/FriendCardPayload.kt | 2 +- .../fabric/v1_21_1/FriendCardPayloadTest.kt | 2 +- .../fabric/v1_21_11/FriendCardPayload.kt | 2 +- .../fabric/v1_21_11/FriendCardPayloadTest.kt | 2 +- .../share/fabric/v26_2/FriendCardPayload.kt | 2 +- .../fabric/v26_2/FriendCardPayloadTest.kt | 2 +- .../v1_20_1/ForgeFriendCardNetworking.kt | 2 +- .../v1_21_1/NeoForgeFriendCardNetworking.kt | 2 +- 11 files changed, 45 insertions(+), 28 deletions(-) diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 2d1e4a592..1aabf2885 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -98,17 +98,27 @@ address, such as a publicly routed host or an explicitly configured network. 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. Copying a friend link is an -explicit disclosure action and may include signed direct candidates. A saved -friend tries fresh mDNS first, then those candidates; requests, presence, and -removal must never use Connect. +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 internet-direct disabled on either peer, confirm the guest does not - attempt a direct internet route and uses Connect once. -3. Enable internet-direct on both peers. Confirm both UIs disclose that the - path reveals public IP addresses before it is attempted. +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 diff --git a/docs/connect-share.md b/docs/connect-share.md index 539e5f2b4..0982d7ca4 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -19,11 +19,14 @@ IP addresses or create a new link for every world. requests and presence themselves are authenticated libp2p traffic and never use Connect as a social relay. -Friend links carry signed direct candidates when the local libp2p 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. A reciprocal card exchange refreshes saved candidates when -friends reconnect from a new network. No circuit relay is accepted. +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, @@ -53,11 +56,15 @@ or blocking cannot be bypassed with an old attempt. - 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. 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. +- 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. 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 index 84f384b08..151a977c3 100644 --- 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 @@ -1,8 +1,8 @@ package com.minekube.connect.share.fabric.v1_20_1 -import net.minecraft.resources.ResourceLocation -import net.minecraft.network.FriendlyByteBuf import java.util.UUID +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.resources.ResourceLocation data class FriendCardPayload( val invitation: String, 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 index deccf15b4..517b518d7 100644 --- 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 @@ -1,10 +1,10 @@ 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 -import java.util.UUID data class FriendCardPayload( val invitation: String, 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 index ac78d8857..e5a1163d6 100644 --- 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 @@ -1,11 +1,11 @@ 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 -import java.util.UUID class FriendCardPayloadTest { @Test 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 index f0b67c81d..e4a7bed57 100644 --- 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 @@ -1,10 +1,10 @@ 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 -import java.util.UUID data class FriendCardPayload( val invitation: String, 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 index 629b6fd94..746bd4980 100644 --- 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 @@ -1,11 +1,11 @@ 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 -import java.util.UUID class FriendCardPayloadTest { @Test 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 index 99fd5056b..dc048c503 100644 --- 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 @@ -1,10 +1,10 @@ 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 -import java.util.UUID data class FriendCardPayload( val invitation: String, 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 index 71dc515a2..12e48bee9 100644 --- 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 @@ -1,11 +1,11 @@ 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 -import java.util.UUID class FriendCardPayloadTest { @Test 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 index c8575f282..40c78ded2 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -18,7 +19,6 @@ import net.minecraftforge.network.NetworkDirection import net.minecraftforge.network.NetworkRegistry import net.minecraftforge.network.PacketDistributor import net.minecraftforge.network.simple.SimpleChannel -import java.util.UUID object ForgeFriendCardNetworking { private const val PROTOCOL = "1" 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 index aeb13b987..0ede63d1e 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -16,7 +17,6 @@ import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import net.neoforged.neoforge.network.PacketDistributor import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent -import java.util.UUID object NeoForgeFriendCardNetworking { private const val PROTOCOL = "1" From 8d200e219f244387d4ab71416d9dbdec19b073df Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 02:29:34 +0200 Subject: [PATCH 057/188] no-mistakes(review): Persisted internet consent across all Fabric friend requests --- .../connect/share/friend/FriendStore.kt | 6 +++++- .../connect/share/friend/FriendStoreTest.kt | 20 +++++++++++++++++++ .../share/fabric/v1_20_1/ShareJoinScreen.kt | 1 + .../share/fabric/v1_21_1/ShareJoinScreen.kt | 1 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 1 + .../share/fabric/v26_2/ShareJoinScreen.kt | 1 + .../share/fabric/ui/FriendsViewModel.kt | 8 +++++++- .../share/fabric/ui/FriendsViewModelTest.kt | 17 ++++++++++++++++ 8 files changed, 53 insertions(+), 2 deletions(-) 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 index 04c41ab81..9873a9ac8 100644 --- 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 @@ -203,6 +203,7 @@ class FriendStore( displayName: String, now: Instant = Instant.now(), relationshipId: UUID = UUID.randomUUID(), + internetDirectGuestOptIn: Boolean = false, ): Either = storeInvitation( invitationUri = invitationUri, @@ -211,6 +212,7 @@ class FriendStore( FriendRelationshipStatus.PENDING_OUTGOING, now = now, relationshipId = relationshipId, + internetDirectGuestOptIn = internetDirectGuestOptIn, ) @Synchronized @@ -227,6 +229,7 @@ class FriendStore( displayName: String, relationshipStatus: FriendRelationshipStatus, allowAutomaticJoin: Boolean = false, + internetDirectGuestOptIn: Boolean = false, now: Instant, relationshipId: UUID?, ): Either = either { @@ -276,7 +279,8 @@ class FriendStore( connectAddress = invite.payload.connectAddress, internetDirectEnabled = invite.payload.internetDirectEnabled, directCandidates = invite.payload.directCandidates, - internetDirectGuestOptIn = existing?.internetDirectGuestOptIn == true, + internetDirectGuestOptIn = internetDirectGuestOptIn || + existing?.internetDirectGuestOptIn == true, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = (existing?.permissions ?: FriendPermissions()) 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 index 180b839aa..76c3b0f1f 100644 --- 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 @@ -91,6 +91,26 @@ class FriendStoreTest { 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) 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 index efd89e3da..be7c3dc63 100644 --- 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 @@ -829,6 +829,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index 060a8c690..bbb753321 100644 --- 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 @@ -821,6 +821,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index ae8d1b7ea..c4d1f6397 100644 --- 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 @@ -824,6 +824,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index bb7a34451..89780d586 100644 --- 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 @@ -824,6 +824,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index 3782d334c..268e88714 100644 --- 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 @@ -92,8 +92,14 @@ class FriendsViewModel( invitationUri: String, displayName: String, now: Instant = Instant.now(), + internetDirectGuestOptIn: Boolean = false, ): String? = - store.sendRequest(invitationUri, displayName, now).fold( + store.sendRequest( + invitationUri = invitationUri, + displayName = displayName, + now = now, + internetDirectGuestOptIn = internetDirectGuestOptIn, + ).fold( ifLeft = { failure -> update { copy(safeMessage = failure.safeMessage) } null 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 index ae367a183..462052f60 100644 --- 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 @@ -59,6 +59,23 @@ class FriendsViewModelTest { 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)) From 59c33e4ad544d4903620da157cc7013e03559d4a Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 04:00:19 +0200 Subject: [PATCH 058/188] no-mistakes(document): Corrected Connect Share docs and acceptance paths --- .../skills/connect-share-prism-e2e/SKILL.md | 5 +++ README.md | 15 +++------ docs/connect-share-testing.md | 33 +++++++++---------- docs/connect-share.md | 9 ++--- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index e94796bec..dccfc1087 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -9,6 +9,11 @@ Use the repository's opt-in live harness to prove the complete friend-to-world flow. Treat discovery, activity, 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. diff --git a/README.md b/README.md index a8c9125a4..1fe20d99b 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,9 @@ Please refer to https://connect.minekube.com for more documentation. ## Connect Share mod Connect Share is a client-side Fabric, Forge, and NeoForge mod. -It supports Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and -NeoForge 1.21.1. It shares a singleplayer world through Minekube Connect or -directly between two modded clients without exposing Minecraft's listener to -the LAN or internet. +It shares a singleplayer world through Minekube Connect or directly between +two modded clients without exposing Minecraft's listener to the LAN or +internet. The current implementation provides: @@ -42,12 +41,8 @@ The current implementation provides: - follow-next-session intents that never interrupt active gameplay; and - isolated, version-and-loader-labelled artifacts for every supported target. -Fabric builds require Fabric API and Fabric Language Kotlin. Forge and NeoForge -builds require the installable Kotlin for Forge `-all.jar`. Marketplace release -metadata declares the matching dependencies so compatible launchers, including -Prism, can install them automatically. Connect Share is MIT licensed and may be -included in modpacks without asking for additional permission. See -[the player, privacy, and distribution guide](docs/connect-share.md). +See [the player, privacy, installation, and distribution guide](docs/connect-share.md) +for the supported versions, required dependencies, and release details. The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 1aabf2885..adb33b1f1 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,11 +1,11 @@ # Connect Share acceptance -Connect Share is built separately for Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2, -Forge 1.20.1, and NeoForge 1.21.1 on their respective Java toolchains. 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. +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. @@ -25,13 +25,9 @@ From the repository root: Use the unclassified versioned JAR in each module's `build/libs` directory. Do not install `sources`, `dev`, `unshaded`, or `parent-shadow` artifacts. -Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. -Marketplace installs must resolve the latter two automatically. - -For Forge or NeoForge, install the matching loader and Kotlin for Forge. A -manual install must use Kotlin for Forge's `-all.jar`; its plain Maven artifact -is only a compile/library artifact and is not recognized as the loader mod. -Marketplace installs must resolve Kotlin for Forge automatically. +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 @@ -94,7 +90,7 @@ route works. ## Invitation, internet-direct, and fallback behavior Internet-direct is best-effort and requires an actually reachable public -address, such as a publicly routed host or an explicitly configured network. +address from a host network interface. The mod does not open a public Minecraft listener, configure UPnP, or use a self-hosted libp2p relay. @@ -164,7 +160,7 @@ 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 + 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 @@ -185,8 +181,11 @@ The nested payload must include ## Real Prism matrix -Use the opt-in `PrismFriendJoinE2ETest` harness for each of the six packaged -artifacts. Run it with `--rerun-tasks`: its live environment variables are +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 diff --git a/docs/connect-share.md b/docs/connect-share.md index 0982d7ca4..b278b0cc5 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -12,8 +12,8 @@ IP addresses or create a new link for every world. 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 to - join**. The host gets an in-game notification and can allow or deny it. +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 @@ -97,5 +97,6 @@ publication additionally requires the repository's project IDs and publisher credentials; the workflow fails closed when they are absent. Forge and NeoForge reuse the loader-neutral Kotlin core and version-specific -Minecraft UI/bridge adapters. Their packaged artifacts pass the same real -two-client Prism host/join gate as the Fabric artifacts. +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). From f8caee74460f57fafdd0554d14065281a6146ee4 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 12:12:32 +0200 Subject: [PATCH 059/188] feat(share): deliver polished social UX --- .../skills/connect-share-prism-e2e/SKILL.md | 33 +- share/AGENTS.md | 5 + .../v1_20_1/mixin/PauseScreenMixin.java | 52 +- .../v1_20_1/mixin/TitleScreenMixin.java | 25 +- .../fabric/v1_20_1/BlockedFriendsScreen.kt | 122 +++- .../v1_20_1/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v1_20_1/EndpointIdentityScreen.kt | 178 +++-- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 645 ++++++++++++------ .../fabric/v1_20_1/SharePrivacyScreen.kt | 83 ++- .../share/fabric/v1_20_1/ShareSetupScreen.kt | 199 ++++-- .../share/fabric/v1_20_1/ShareStatusScreen.kt | 320 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../fabric/v1_20_1/Fabric12111ArtifactTest.kt | 10 +- .../v1_21_1/mixin/PauseScreenMixin.java | 52 +- .../v1_21_1/mixin/TitleScreenMixin.java | 25 +- .../fabric/v1_21_1/BlockedFriendsScreen.kt | 122 +++- .../v1_21_1/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v1_21_1/EndpointIdentityScreen.kt | 178 +++-- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 628 +++++++++++------ .../fabric/v1_21_1/SharePrivacyScreen.kt | 80 ++- .../share/fabric/v1_21_1/ShareSetupScreen.kt | 187 +++-- .../share/fabric/v1_21_1/ShareStatusScreen.kt | 320 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../fabric/v1_21_1/Fabric12111ArtifactTest.kt | 10 +- .../v1_21_11/mixin/PauseScreenMixin.java | 52 +- .../v1_21_11/mixin/TitleScreenMixin.java | 25 +- .../fabric/v1_21_11/BlockedFriendsScreen.kt | 124 +++- .../v1_21_11/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v1_21_11/EndpointIdentityScreen.kt | 178 +++-- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 635 +++++++++++------ .../fabric/v1_21_11/SharePrivacyScreen.kt | 80 ++- .../share/fabric/v1_21_11/ShareSetupScreen.kt | 181 +++-- .../fabric/v1_21_11/ShareStatusScreen.kt | 322 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../v1_21_11/Fabric12111ArtifactTest.kt | 10 +- .../fabric/v26_2/mixin/PauseScreenMixin.java | 52 +- .../fabric/v26_2/mixin/TitleScreenMixin.java | 25 +- .../fabric/v26_2/BlockedFriendsScreen.kt | 124 +++- .../v26_2/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v26_2/EndpointIdentityScreen.kt | 172 +++-- .../share/fabric/v26_2/ShareJoinScreen.kt | 632 +++++++++++------ .../share/fabric/v26_2/SharePrivacyScreen.kt | 80 ++- .../share/fabric/v26_2/ShareSetupScreen.kt | 181 +++-- .../share/fabric/v26_2/ShareStatusScreen.kt | 322 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../fabric/v26_2/Fabric262ArtifactTest.kt | 10 +- .../share/fabric/ConnectShareClient.kt | 22 + .../share/fabric/ui/AdaptiveShareLayout.kt | 109 +++ .../fabric/ui/ShareScreenPresentation.kt | 201 ++++++ .../share/fabric/PrismFriendJoinE2ETest.kt | 39 +- .../fabric/ui/AdaptiveShareLayoutTest.kt | 48 ++ .../fabric/ui/ShareScreenPresentationTest.kt | 192 ++++++ 56 files changed, 6167 insertions(+), 2135 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index dccfc1087..8e5119414 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -73,6 +73,7 @@ Start it after the host world is ready: LIVE_DATA= \ LIVE_PORT_FILE= \ LIVE_HOST_LOG= \ +LIVE_GUEST_LOG= \ LIVE_PLAYER_NAME= \ ./gradlew :share:fabric-common:test \ --tests '*PrismFriendJoinE2ETest*' --no-parallel @@ -86,12 +87,42 @@ The test must remain running while the external guest uses the port written to 3. A dedicated direct proxy answers a real Minecraft status probe. 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. +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. + +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 diff --git a/share/AGENTS.md b/share/AGENTS.md index 23afe60f5..555c5ae53 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -128,3 +128,8 @@ redesigned for Kotlin. and name it from the loader-specific mixin config. Forge and NeoForge client resources need a compatible `pack.mcmeta`, otherwise startup can stop at a resource-pack warning before quick-play E2E begins. +- Visual QA is keyboard-only at both the normal Prism window size and 640x400. + A focused Minecraft `EditBox` hides its hint, so every input needs a + persistent label; split pause-menu buttons must keep copy within their + 100-pixel logical width. The repository Prism skill owns the capture and + focus-order procedure. 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 index c1799c89d..fb8bc3091 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java index 34bc29bed..26f3ecca3 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index 2ff433f56..f7ed4235d 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,27 +14,39 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, ), ) @@ -38,33 +54,89 @@ class BlockedFriendsScreen( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index 06a07a2f3..f9c931c72 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v1_20_1 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft!!.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft!!.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft!!.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt index 09b96e09e..954821569 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,49 +157,66 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + 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) + minecraft!!.setScreen(parent) } private fun confirmReset() { - minecraft?.setScreen( + minecraft!!.setScreen( ConfirmScreen( { confirmed -> if (confirmed) { viewModel.resetIdentity() } - minecraft?.setScreen(this) + minecraft!!.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt index be7c3dc63..3538520a3 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft!!.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,18 +224,23 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20).build().apply { - setTooltip(pageTooltip) - }, + }.bounds(layout.contentX, layout.headerY, 24, 20) + .tooltip(pageTooltip) + .build(), ) previous.active = page.hasPrevious val next = addRenderableWidget( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20).build().apply { - setTooltip(pageTooltip) - }, + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) + .tooltip(pageTooltip) + .build(), ) next.active = page.hasNext } @@ -223,7 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -232,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -249,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -269,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -283,7 +336,7 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, ), @@ -293,27 +346,40 @@ class ShareJoinScreen( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -327,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -339,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -448,48 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - ObservableCheckbox( - width / 2 - 155, - 112, - 310, + 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.join.offline"), - offlineSelected, - { selected -> offlineSelected = selected }, + Component.translatable("connect_share.friends.name"), ).apply { - setTooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.offline.tooltip", - ), - ), - ) + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } }, ) - internetDirect = addRenderableWidget( - ObservableCheckbox( - width / 2 - 155, - 134, - 310, + 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, - Component.translatable("connect_share.join.internet"), - internetSelected, - { selected -> internetSelected = selected }, - ).apply { - setTooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - }, + ).build(), ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 160), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -500,23 +606,109 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -528,21 +720,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -556,9 +761,9 @@ class ShareJoinScreen( ) val notify = addRenderableWidget( ObservableCheckbox( - width / 2 - 155, - 82, - 310, + layout.contentX, + layout.bodyTop + 28, + layout.contentWidth, 20, Component.translatable("connect_share.friends.notify"), friend.permissions.notifyWhenOnline, @@ -575,42 +780,44 @@ class ShareJoinScreen( ).withInitialValue(accessPolicy) .withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, ) val shareWorlds = addRenderableWidget( ObservableCheckbox( - width / 2 - 155, - 104, - 310, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, 20, Component.translatable("connect_share.friends.share_worlds"), friend.permissions.canSeeMyWorlds, ), ) - val guestInternetDirect = addRenderableWidget( - ObservableCheckbox( - width / 2 - 155, - 126, - 310, - 20, - Component.translatable( - "connect_share.friends.internet_direct", - ), - friend.internetDirectGuestOptIn, - ), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -623,7 +830,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -639,7 +846,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -647,24 +859,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -672,7 +895,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -695,7 +919,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -715,13 +944,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1062,6 +1301,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1070,59 +1310,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1171,17 +1396,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1202,7 +1429,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index ea50255ba..c49e7a8c5 100644 --- 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 @@ -1,8 +1,12 @@ 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 @@ -14,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -44,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -64,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -93,11 +135,12 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( ObservableCheckbox( - width / 2 - 155, + layout.contentX, y, - 310, + layout.contentWidth, 20, Component.translatable("connect_share.privacy.$key"), selected, @@ -105,4 +148,16 @@ class SharePrivacyScreen( ), ) } + + 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 index b8fd99d56..3156e7ac4 100644 --- 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 @@ -2,8 +2,14 @@ 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 @@ -15,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft!!.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.allowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, - ).withValues(ShareGameMode.entries) - .withInitialValue(current.options.gameMode) + ).withInitialValue(current.options.gameMode) + .withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -48,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -58,28 +160,27 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.builder( { guests: Int -> Component.literal(guests.toString()) }, - ).withValues((1..16).toList()) - .withInitialValue(current.options.maxGuests) + ).withInitialValue(current.options.maxGuests) + .withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, ) addRenderableWidget( ObservableCheckbox( - width / 2 - 155, - 126, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.setup.internet"), current.options.allowInternetDirect, - { allowed -> - viewModel.setAllowInternetDirect(allowed) - }, - ).apply { + ) { allowed -> + viewModel.setAllowInternetDirect(allowed) + }.apply { setTooltip( Tooltip.create( Component.translatable( @@ -89,35 +190,6 @@ class ShareSetupScreen( ) }, ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft!!.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -135,11 +207,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt index c27494d70..5ba936a9c 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft!!.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ), + ) { + invitation?.let { + minecraft!!.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft!!.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, ), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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")) { + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { viewModel.stop() minecraft!!.setScreen(parent) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( minecraft!!.setScreen(parent) } - 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 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index 4bc15a011..23aa3bf1b 100644 --- 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 @@ -58,12 +58,12 @@ class Fabric1201ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -75,11 +75,11 @@ class Fabric1201ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index da981f25c..54cfe3580 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java index a89542e96..e64bbc930 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index dec11f361..e5f92d7a6 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,27 +14,39 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, ), ) @@ -38,33 +54,89 @@ class BlockedFriendsScreen( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index 3570aa65e..7de1d9ffd 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v1_21_1 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft!!.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft!!.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft!!.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt index ddc95d129..3dd629db1 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,49 +157,66 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + 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) + minecraft!!.setScreen(parent) } private fun confirmReset() { - minecraft?.setScreen( + minecraft!!.setScreen( ConfirmScreen( { confirmed -> if (confirmed) { viewModel.resetIdentity() } - minecraft?.setScreen(this) + minecraft!!.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt index bbb753321..999a4e690 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft!!.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,7 +224,7 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20) + }.bounds(layout.contentX, layout.headerY, 24, 20) .tooltip(pageTooltip) .build(), ) @@ -213,7 +233,12 @@ class ShareJoinScreen( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20) + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) .tooltip(pageTooltip) .build(), ) @@ -223,7 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -232,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -249,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -269,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -283,7 +336,7 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, ), @@ -293,27 +346,40 @@ class ShareJoinScreen( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -327,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -339,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -448,46 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.join.offline"), + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), font, - ).pos(width / 2 - 155, 112) - .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"), + nameBox = addRenderableWidget( + EditBox( font, - ).pos(width / 2 - 155, 134) - .selected(internetSelected) - .onValueChange { _, selected -> - internetSelected = selected + 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() } - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - .build(), + }, + ) + 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( - centered(Component.literal(message), 160), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -498,23 +606,107 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -526,21 +718,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -556,7 +761,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(width / 2 - 155, 82) + ).pos(layout.contentX, layout.bodyTop + 28) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -571,9 +776,9 @@ class ShareJoinScreen( ).withInitialValue(accessPolicy) .withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, @@ -582,27 +787,31 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(width / 2 - 155, 104) + ).pos(layout.contentX, layout.bodyTop + 50) .selected(friend.permissions.canSeeMyWorlds) .build(), ) - val guestInternetDirect = addRenderableWidget( - Checkbox.builder( - Component.translatable( - "connect_share.friends.internet_direct", - ), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.internetDirectGuestOptIn) - .build(), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -615,7 +824,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -631,7 +840,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -639,24 +853,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -664,7 +889,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -687,7 +913,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -707,13 +938,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1004,7 +1245,6 @@ class ShareJoinScreen( } private fun connect(target: GuestJoinTarget) { - val client = checkNotNull(minecraft) val address = when (target) { is GuestJoinTarget.Connect -> ServerAddress.parseString(target.publicAddress) @@ -1040,7 +1280,7 @@ class ShareJoinScreen( } ConnectScreen.startConnecting( parent, - client, + checkNotNull(minecraft), address, data, false, @@ -1055,6 +1295,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1063,59 +1304,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1164,17 +1390,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1195,7 +1423,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index ca93228a5..7816eed26 100644 --- 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 @@ -1,9 +1,12 @@ 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 @@ -15,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -45,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -65,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -94,14 +135,27 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.privacy.$key"), font, - ).pos(width / 2 - 155, y) + ).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 index 5453b9e9d..a75ef3837 100644 --- 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 @@ -2,9 +2,14 @@ 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 @@ -16,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft!!.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.isAllowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, - ).withValues(ShareGameMode.entries) - .withInitialValue(current.options.gameMode) + ).withInitialValue(current.options.gameMode) + .withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -49,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -59,12 +160,12 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.builder( { guests: Int -> Component.literal(guests.toString()) }, - ).withValues((1..16).toList()) - .withInitialValue(current.options.maxGuests) + ).withInitialValue(current.options.maxGuests) + .withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, @@ -73,7 +174,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 126) + ).pos(layout.contentX, layout.bodyTop + 74) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,35 +188,6 @@ class ShareSetupScreen( ) .build(), ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft!!.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -133,11 +205,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt index 3e7b03c02..623f19c21 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft!!.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ), + ) { + invitation?.let { + minecraft!!.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft!!.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, ), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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")) { + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { viewModel.stop() minecraft!!.setScreen(parent) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( minecraft!!.setScreen(parent) } - 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 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index 1d972d435..a1181d1d9 100644 --- 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 @@ -27,12 +27,12 @@ class Fabric1211ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -44,11 +44,11 @@ class Fabric1211ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index af243eb23..4aaa1fa4c 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java index 05db86ab3..dd6842a68 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index e048d7291..ca424d4e5 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,61 +14,129 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, - ), + ).setMaxWidth(layout.contentWidth - buttonWidth - 6), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index be256e22a..dcc971a29 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v1_21_11 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt index e00c80162..cc26e338f 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,49 +157,66 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + 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) + minecraft.setScreen(parent) } private fun confirmReset() { - minecraft?.setScreen( + minecraft.setScreen( ConfirmScreen( { confirmed -> if (confirmed) { viewModel.resetIdentity() } - minecraft?.setScreen(this) + minecraft.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt index c4d1f6397..ccde70a2f 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,7 +224,7 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20) + }.bounds(layout.contentX, layout.headerY, 24, 20) .tooltip(pageTooltip) .build(), ) @@ -213,7 +233,12 @@ class ShareJoinScreen( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20) + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) .tooltip(pageTooltip) .build(), ) @@ -223,8 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -233,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -250,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -270,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -284,37 +336,50 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, - ).setMaxWidth(174), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -328,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -340,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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(242 - actionWidth), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( - Component.translatable( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -449,47 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.join.offline"), + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), font, - ).pos(width / 2 - 155, 112) - .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"), + nameBox = addRenderableWidget( + EditBox( font, - ).pos(width / 2 - 155, 134) - .selected(internetSelected) - .onValueChange { _, selected -> - internetSelected = selected + 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() } - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - .build(), + }, + ) + 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( - centered(Component.literal(message), 160) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -500,23 +606,107 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -528,21 +718,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -558,7 +761,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(width / 2 - 155, 82) + ).pos(layout.contentX, layout.bodyTop + 28) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -573,9 +776,9 @@ class ShareJoinScreen( accessPolicy, ).withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, @@ -584,28 +787,31 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(width / 2 - 155, 104) + ).pos(layout.contentX, layout.bodyTop + 50) .selected(friend.permissions.canSeeMyWorlds) .build(), ) - val guestInternetDirect = addRenderableWidget( - Checkbox.builder( - Component.translatable( - "connect_share.friends.internet_direct", - ), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.internetDirectGuestOptIn) - .build(), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176) - .setMaxWidth(CONTENT_WIDTH), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -618,7 +824,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -634,7 +840,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -642,24 +853,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -667,7 +889,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -690,7 +913,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -710,13 +938,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1007,7 +1245,6 @@ class ShareJoinScreen( } private fun connect(target: GuestJoinTarget) { - val client = minecraft val address = when (target) { is GuestJoinTarget.Connect -> ServerAddress.parseString(target.publicAddress) @@ -1043,7 +1280,7 @@ class ShareJoinScreen( } ConnectScreen.startConnecting( parent, - client, + minecraft, address, data, false, @@ -1058,6 +1295,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1066,59 +1304,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1167,17 +1390,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1198,7 +1423,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index f211e2d40..61cd7199b 100644 --- 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 @@ -1,9 +1,12 @@ 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 @@ -15,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -45,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -65,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -94,14 +135,27 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.privacy.$key"), font, - ).pos(width / 2 - 155, y) + ).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 index 3bf49f46b..56cc7f524 100644 --- 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 @@ -2,9 +2,14 @@ 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 @@ -16,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.isAllowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, - ).setMaxWidth(CONTENT_WIDTH), + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, current.options.gameMode, ).withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -49,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -62,9 +163,9 @@ class ShareSetupScreen( current.options.maxGuests, ).withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, @@ -73,7 +174,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 126) + ).pos(layout.contentX, layout.bodyTop + 74) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,35 +188,6 @@ class ShareSetupScreen( ) .build(), ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ).setMaxWidth(CONTENT_WIDTH), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -133,11 +205,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt index 8f479f162..88f15c66e 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ).setMaxWidth(CONTENT_WIDTH), + ) { + invitation?.let { + minecraft.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ).setMaxWidth(CONTENT_WIDTH), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, - ).setMaxWidth(202), + ).setMaxWidth(labelWidth), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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.status.stop")) { + 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(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( minecraft.setScreen(parent) } - 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 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index fa1eb77d7..7c02088e5 100644 --- 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 @@ -27,12 +27,12 @@ class Fabric12111ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -44,11 +44,11 @@ class Fabric12111ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index f873b5b30..32755099b 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java index 6b7d83b8c..bfd176ae7 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index 25578a66f..25c4e968e 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,61 +14,129 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, - ), + ).setMaxWidth(layout.contentWidth - buttonWidth - 6), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index 0fb980014..29ed1e988 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v26_2 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft.gui.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft.gui.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt index 3d684381a..32c93b7a6 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,29 +157,42 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + val next = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + if ( + next != fingerprint || + state.importDraft.token.isEmpty() && + tokenBox?.value?.isNotEmpty() == true + ) { rebuildWidgets() } } @@ -144,8 +211,12 @@ class EndpointIdentityScreen( } minecraft.gui.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt index 89780d586..7db8edddf 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft.gui.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,7 +224,7 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20) + }.bounds(layout.contentX, layout.headerY, 24, 20) .tooltip(pageTooltip) .build(), ) @@ -213,7 +233,12 @@ class ShareJoinScreen( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20) + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) .tooltip(pageTooltip) .build(), ) @@ -223,8 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -233,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -250,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -270,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -284,37 +336,50 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, - ).setMaxWidth(174), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -328,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -340,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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(242 - actionWidth), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( - Component.translatable( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -449,47 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.join.offline"), + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), font, - ).pos(width / 2 - 155, 112) - .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"), + nameBox = addRenderableWidget( + EditBox( font, - ).pos(width / 2 - 155, 134) - .selected(internetSelected) - .onValueChange { _, selected -> - internetSelected = selected + 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() } - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - .build(), + }, + ) + 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( - centered(Component.literal(message), 160) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -500,23 +606,107 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -528,21 +718,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -558,7 +761,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(width / 2 - 155, 82) + ).pos(layout.contentX, layout.bodyTop + 28) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -573,9 +776,9 @@ class ShareJoinScreen( accessPolicy, ).withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, @@ -584,28 +787,31 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(width / 2 - 155, 104) + ).pos(layout.contentX, layout.bodyTop + 50) .selected(friend.permissions.canSeeMyWorlds) .build(), ) - val guestInternetDirect = addRenderableWidget( - Checkbox.builder( - Component.translatable( - "connect_share.friends.internet_direct", - ), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.internetDirectGuestOptIn) - .build(), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176) - .setMaxWidth(CONTENT_WIDTH), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -618,7 +824,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -634,7 +840,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -642,24 +853,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -667,7 +889,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -690,7 +913,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -710,13 +938,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1057,6 +1295,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1065,59 +1304,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1166,17 +1390,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1197,7 +1423,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index c5f3f5b4c..4a04dd306 100644 --- 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 @@ -1,9 +1,12 @@ 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 @@ -15,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -45,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -65,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft.gui.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -94,14 +135,27 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.privacy.$key"), font, - ).pos(width / 2 - 155, y) + ).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 index 5c92adf39..349a5fa14 100644 --- 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 @@ -2,9 +2,14 @@ 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 @@ -16,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.isAllowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, - ).setMaxWidth(CONTENT_WIDTH), + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, current.options.gameMode, ).withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -49,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -62,9 +163,9 @@ class ShareSetupScreen( current.options.maxGuests, ).withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, @@ -73,7 +174,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 126) + ).pos(layout.contentX, layout.bodyTop + 74) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,35 +188,6 @@ class ShareSetupScreen( ) .build(), ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ).setMaxWidth(CONTENT_WIDTH), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft.gui.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft.gui.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -133,11 +205,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt index 032ce83b8..7a8753392 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ).setMaxWidth(CONTENT_WIDTH), + ) { + invitation?.let { + minecraft.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ).setMaxWidth(CONTENT_WIDTH), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft.gui.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, - ).setMaxWidth(202), + ).setMaxWidth(labelWidth), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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.status.stop")) { + 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(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( 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, 9, message, font) - } + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index 44ec7811a..a31c88553 100644 --- 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 @@ -27,12 +27,12 @@ class Fabric262ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -44,11 +44,11 @@ class Fabric262ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index 2db152629..901231dfd 100644 --- 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 @@ -4,6 +4,8 @@ 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 fun interface ConnectShareScreenFactory { fun open(parent: Any, active: Boolean) @@ -64,6 +66,26 @@ object ConnectShareClient { "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 -> 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..69a0b7465 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt @@ -0,0 +1,109 @@ +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 + } +} + +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, + @Suppress("UNUSED_PARAMETER") 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 = 58 + return FormScreenLayout( + contentX = contentX, + contentWidth = contentWidth, + headerY = 14, + subtitleY = 32, + bodyTop = bodyTop, + availableBodyHeight = footerTop - bodyTop, + footerTop = footerTop, + footerBottom = footerBottom, + ) + } + + 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/ShareScreenPresentation.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt new file mode 100644 index 000000000..2687f4aa2 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt @@ -0,0 +1,201 @@ +package com.minekube.connect.share.fabric.ui + +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, +) + +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 || onlineViaLan -> + "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/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt index 998aad2c3..54f9b5e11 100644 --- 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 @@ -38,11 +38,13 @@ class PrismFriendJoinE2ETest { val dataDirectory = Path.of(checkNotNull(dataValue)) val portFile = Path.of(checkNotNull(portValue)) 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 joinedLine = "] $playerName joined the game" val joinsBefore = Files.readString(hostLog) .lineSequence() .count { joinedLine in it } + val guestLoadsBefore = guestLog?.let(::loadedAdvancementsCount) val friend = FriendStore(dataDirectory).all().single() System.getenv("LIVE_HOST_DATA")?.let { hostDataValue -> val guestPeerId = DirectP2pNode( @@ -85,13 +87,17 @@ class PrismFriendJoinE2ETest { ) // Status and gameplay require different one-shot proxies. - assertTrue( - browser.probeLan( - friend, - DirectP2pAuthMode.OFFLINE, - MinecraftStatusProbe(), - ) != null, - ) + withTimeout(30_000) { + while ( + browser.probeLan( + friend, + DirectP2pAuthMode.OFFLINE, + MinecraftStatusProbe(), + ) == null + ) { + delay(250) + } + } val playerUuid = UUID.nameUUIDFromBytes( "OfflinePlayer:$playerName".toByteArray( StandardCharsets.UTF_8, @@ -133,9 +139,28 @@ class PrismFriendJoinE2ETest { delay(100) } } + if (guestLog != null && guestLoadsBefore != null) { + withTimeout(180_000) { + while ( + loadedAdvancementsCount(guestLog) <= + guestLoadsBefore + ) { + delay(100) + } + } + } } } finally { browser.close() } } + + private fun loadedAdvancementsCount(log: Path): Int = + if (Files.exists(log)) { + Files.readString(log).lineSequence().count { + "Loaded " in it && " advancements" in it + } + } else { + 0 + } } 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..3a76ee730 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt @@ -0,0 +1,48 @@ +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) + } +} 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..418718cc0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt @@ -0,0 +1,192 @@ +package com.minekube.connect.share.fabric.ui + +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) + } + + 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, + ) +} From ca865d1f0980c11457b119b49c35b559d44e12e9 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 13:01:36 +0200 Subject: [PATCH 060/188] no-mistakes(review): Preserve Share privacy, recovery, localization, and accessibility --- .../connect/share/friend/FriendStore.kt | 7 +- .../connect/share/friend/FriendStoreTest.kt | 41 +++++++-- .../fabric/v1_20_1/ConnectShare12111Client.kt | 18 +++- .../fabric/v1_20_1/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../fabric/v1_21_1/ConnectShare12111Client.kt | 18 +++- .../fabric/v1_21_1/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../v1_21_11/ConnectShare12111Client.kt | 18 +++- .../fabric/v1_21_11/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../fabric/v26_2/ConnectShare262Client.kt | 18 +++- .../fabric/v26_2/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../share/fabric/ui/FriendsViewModel.kt | 33 +++---- .../fabric/ui/ShareScreenPresentation.kt | 2 +- .../connect/share/fabric/ui/ShareUiMessage.kt | 90 +++++++++++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 29 +++--- .../share/fabric/ui/FriendsViewModelTest.kt | 35 +++++++- .../share/fabric/ui/ShareViewModelTest.kt | 25 +++++- 28 files changed, 766 insertions(+), 188 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt 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 index 9873a9ac8..1f40b5ca9 100644 --- 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 @@ -360,7 +360,12 @@ class FriendStore( friend = removed, removedAt = now, ) - write(StoreData(remaining, removals)) + write( + data().copy( + friends = remaining, + removals = removals, + ), + ) return true } 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 index 76c3b0f1f..01b8ed304 100644 --- 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 @@ -347,6 +347,31 @@ class FriendStoreTest { ) } + @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) @@ -460,30 +485,34 @@ class FriendStoreTest { 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 = SHARE_ID, + shareId = shareId, expiresAtEpochMillis = expiresAt.toEpochMilli(), connectAddress = CONNECT_ADDRESS, - peerId = PEER_ID, + peerId = peerId, internetDirectEnabled = internetDirectEnabled, directCandidates = directCandidates, - capability = CAPABILITY, + capability = capability, ) val unsigned = ShareInviteCodec.unsignedBytes( payload, - KEY_PAIR.public.encoded, + keyPair.public.encoded, ) val signature = Signature.getInstance("Ed25519").run { - initSign(KEY_PAIR.private) + initSign(keyPair.private) update(unsigned) sign() } return ShareInviteCodec.encode( SignedShareInvite( payload = payload, - publicKey = KEY_PAIR.public.encoded, + publicKey = keyPair.public.encoded, signature = signature, ), ) 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 index 1e7014276..3a5a0a85f 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -334,7 +336,7 @@ class ConnectShare1201Runtime( minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -413,16 +415,28 @@ class ConnectShare1201Runtime( 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 954821569..5232e5aa3 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt index 3538520a3..54c8110ae 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft!!.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -734,7 +754,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1042,7 +1062,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1099,9 +1119,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1114,10 +1132,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1136,9 +1154,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1157,10 +1175,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1177,18 +1195,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1215,7 +1231,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1246,7 +1262,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1366,9 +1382,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index d8fe5f171..ddd260c03 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -334,7 +336,7 @@ class ConnectShare1211Runtime( minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -418,16 +420,28 @@ class ConnectShare1211Runtime( 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 3dd629db1..ff5200f30 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt index 999a4e690..83fca4931 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft!!.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -732,7 +752,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1036,7 +1056,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1093,9 +1113,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1108,10 +1126,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1130,9 +1148,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1151,10 +1169,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1171,18 +1189,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1209,7 +1225,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1240,7 +1256,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1360,9 +1376,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index cd589871c..d2cd5b0ed 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -350,7 +352,7 @@ class ConnectShare12111Client : ClientModInitializer { minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -434,16 +436,28 @@ class ConnectShare12111Client : ClientModInitializer { 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index cc26e338f..eaaa99b40 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt index ccde70a2f..c1f104488 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -732,7 +752,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1036,7 +1056,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1093,9 +1113,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1108,10 +1126,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1130,9 +1148,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1151,10 +1169,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1171,18 +1189,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1209,7 +1225,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1240,7 +1256,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1360,9 +1376,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 78dccc222..6b7a47a28 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -350,7 +352,7 @@ class ConnectShare262Client : ClientModInitializer { minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -434,16 +436,28 @@ class ConnectShare262Client : ClientModInitializer { 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 32c93b7a6..466e2e896 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt index 7db8edddf..6d7ca0d17 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -732,7 +752,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1036,7 +1056,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1093,9 +1113,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1108,10 +1126,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1130,9 +1148,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1151,10 +1169,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1171,18 +1189,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1209,7 +1225,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1240,7 +1256,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1360,9 +1376,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 268e88714..2a32d2391 100644 --- 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 @@ -69,7 +69,7 @@ data class FriendsUiState( val outgoingRequests: List = emptyList(), val incomingRequests: List = emptyList(), val blocked: List = emptyList(), - val safeMessage: String? = null, + val safeMessage: ShareUiMessage? = null, ) class FriendsViewModel( @@ -101,7 +101,7 @@ class FriendsViewModel( internetDirectGuestOptIn = internetDirectGuestOptIn, ).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } null }, ifRight = { request -> @@ -121,7 +121,7 @@ class FriendsViewModel( fun rename(peerId: String, displayName: String) { store.rename(peerId, displayName).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { refresh() @@ -135,7 +135,7 @@ class FriendsViewModel( ) { store.updatePermissions(peerId, permissions).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { refresh() @@ -149,7 +149,7 @@ class FriendsViewModel( ) { store.setInternetDirectGuestOptIn(peerId, enabled).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { refresh() }, ) @@ -410,21 +410,22 @@ class FriendsViewModel( activity.kind == FriendActivityKind.HOSTING_WORLD && remote != null ), - canJoinNow = remote != null && - 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 { - const val FRIENDS_LOAD_FAILURE = - "Saved Connect Share friends could not be loaded" - const val FRIEND_REMOVE_FAILURE = - "This Connect Share friend could not be removed" - const val FRIEND_BLOCK_FAILURE = - "This Connect Share identity could not be blocked" - const val FRIEND_UNBLOCK_FAILURE = - "This Connect Share identity could not be unblocked" + 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/ShareScreenPresentation.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt index 2687f4aa2..61e947b26 100644 --- 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 @@ -85,7 +85,7 @@ fun FriendSummary.presentation(): FriendRowPresentation { "connect_share.friends.status.server" to listOf(activityDescription ?: "Minecraft server") - canJoinNow || onlineViaLan -> + canJoinNow -> "connect_share.friends.status.ready" to emptyList() activityKind == FriendActivityKind.ONLINE || onlineViaConnect -> 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..0af87934f --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt @@ -0,0 +1,90 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.ShareLifecycleError +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(), +) + +fun FriendStoreError.uiMessage(): ShareUiMessage = when (this) { + is FriendStoreError.InvalidInvitation -> + ShareUiMessage("connect_share.error.invalid_invitation") + 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 -> + ShareUiMessage("connect_share.error.invalid_invitation") + 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 index 5fc38a7ca..82087d606 100644 --- 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 @@ -59,7 +59,7 @@ data class ShareUiState( val identity: EndpointIdentitySummary? = null, val importDraft: IdentityImportDraft = IdentityImportDraft(), val operationInProgress: Boolean = false, - val safeMessage: String? = null, + val safeMessage: ShareUiMessage? = null, ) { val startEnabled: Boolean get() = worldAvailable && @@ -341,7 +341,7 @@ class ShareViewModel( ) { result.fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { identity -> onIdentityChanged() @@ -365,7 +365,7 @@ class ShareViewModel( private suspend fun startCurrentWorld() { startShare(state.value.options).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { update { @@ -381,7 +381,7 @@ class ShareViewModel( private suspend fun stopCurrentWorld() { stopShare().fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { update { @@ -413,13 +413,12 @@ class ShareViewModel( state.value.worldAvailable && state.value.shareState is ShareState.Idle private fun canStopCurrentWorld(): Boolean = when (state.value.shareState) { - ShareState.Idle, - is ShareState.Failed, - -> false + ShareState.Idle -> false ShareState.Starting, is ShareState.Sharing, ShareState.Stopping, + is ShareState.Failed, -> true } @@ -454,14 +453,14 @@ class ShareViewModel( ) private companion object { - const val MANAGED_MESSAGE = - "Connect credentials are managed by the environment" - const val GENERIC_FAILURE_MESSAGE = - "Could not update Connect Share" - const val IDENTITY_ACTIVE_MESSAGE = - "Stop sharing before changing Connect credentials" - const val PREFERENCES_FAILURE_MESSAGE = - "Connect Share privacy settings could not be saved" + 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") } } 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 index 462052f60..c6829bc32 100644 --- 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 @@ -180,7 +180,10 @@ class FriendsViewModelTest { assertEquals(null, accepted) assertTrue(viewModel.state.value.friends.isEmpty()) - assertTrue(viewModel.state.value.safeMessage?.isNotBlank() == true) + assertTrue( + viewModel.state.value.safeMessage?.translationKey?.isNotBlank() == + true, + ) } @Test @@ -326,6 +329,36 @@ class FriendsViewModelTest { 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 `playing on a server exposes request to join instead of direct join`() { val store = FriendStore(tempDir) 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 index 0750e4db5..5b6946d29 100644 --- 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 @@ -50,6 +50,23 @@ class ShareViewModelTest { 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() @@ -105,8 +122,8 @@ class ShareViewModelTest { assertFalse(viewModel.state.value.importDraft.tokenEditable) assertEquals(0, identityActions.importCalls) assertEquals( - "Connect credentials are managed by the environment", - viewModel.state.value.safeMessage, + "connect_share.error.identity_managed", + viewModel.state.value.safeMessage?.translationKey, ) } @@ -227,8 +244,8 @@ class ShareViewModelTest { assertEquals(0, identityActions.importCalls) assertEquals( - "Stop sharing before changing Connect credentials", - viewModel.state.value.safeMessage, + "connect_share.error.identity_active", + viewModel.state.value.safeMessage?.translationKey, ) } From e0d786f3d2ec80a3551f1e3f1d3c23463856695b Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 13:46:29 +0200 Subject: [PATCH 061/188] no-mistakes(document): Consolidate Share docs and correct E2E guidance --- .../skills/connect-share-prism-e2e/SKILL.md | 8 +++---- README.md | 24 ++----------------- docs/connect-share-testing.md | 2 +- 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 8e5119414..a1fe295c9 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -58,10 +58,10 @@ prismlauncher --launch --offline \ `--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 `Published LAN -server`. 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`. +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`. ## Run the opt-in live harness diff --git a/README.md b/README.md index 1fe20d99b..2758f6fad 100644 --- a/README.md +++ b/README.md @@ -20,29 +20,9 @@ It shares a singleplayer world through Minekube Connect or directly between two modded clients without exposing Minecraft's listener to the LAN or internet. -The current implementation provides: - -- a native **Share with friends** flow in the pause menu; -- a native **Friends** flow on the title screen, including **Join Connect Share**; -- one persistent endpoint identity reused across worlds and restarts; -- one authenticated libp2p friend identity, with presence and world details - visible only to confirmed friends; -- import of an existing dashboard endpoint and token, including `token.json`; -- `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; -- a stable `*.play.minekube.net` address for unmodified Java clients; -- signed friend links and temporary world invitations for modded clients; -- automatic same-LAN discovery and direct libp2p transport; -- direct libp2p friend delivery from explicitly shared friend links when a - direct route exists, plus opt-in internet-direct gameplay attempts; -- exactly-once fallback to Connect, which is the only relay; -- host approval before each new guest reaches the world; -- explicit support for authenticated and unverified offline-mode guests; and -- compatibility checks before a friend requests access; -- follow-next-session intents that never interrupt active gameplay; and -- isolated, version-and-loader-labelled artifacts for every supported target. - See [the player, privacy, installation, and distribution guide](docs/connect-share.md) -for the supported versions, required dependencies, and release details. +for the supported versions, required dependencies, player flow, and release +details. The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index adb33b1f1..f93c51c43 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -118,7 +118,7 @@ Connect. 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 one bounded direct attempt is followed by exactly one + 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 From 3b213aaa3afcb19209f3d0f6e4a0387a2d7aa39f Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 13:51:04 +0200 Subject: [PATCH 062/188] no-mistakes(document): Correct Share docs and E2E guidance --- .agents/skills/connect-share-prism-e2e/SKILL.md | 4 ++-- docs/connect-share.md | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index a1fe295c9..55750e771 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -40,7 +40,7 @@ or replace older Connect Share JARs so each instance loads exactly one. Compare SHA-256 digests for the build output and both installed copies. Confirm each fresh `latest.log` contains both Fabric Loader startup and a -`connect-share` mod entry. Fabric Language Kotlin is packaged as a declared mod +`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 @@ -76,7 +76,7 @@ LIVE_HOST_LOG= \ LIVE_GUEST_LOG= \ LIVE_PLAYER_NAME= \ ./gradlew :share:fabric-common:test \ - --tests '*PrismFriendJoinE2ETest*' --no-parallel + --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel ``` The test must remain running while the external guest uses the port written to diff --git a/docs/connect-share.md b/docs/connect-share.md index b278b0cc5..780412803 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -49,8 +49,10 @@ or blocking cannot be bypassed with an old attempt. - Only confirmed peer identities receive presence. Display names are labels, never identity or authorization. -- Online, playing, current server/world name, and joinable state can each be - hidden independently under **Privacy**. +- Online, playing, and joinable state can each be hidden independently under + **Privacy**. When a friend is on another server, **Show current server** can + also hide that server's name; the current singleplayer world name remains + visible while hosting. - Each friend can be set to **Ask Every Time**, **Auto-Accept**, or **Never Allow**. The default is Ask Every Time. - Removing a friend revokes future presence and admissions and is synchronized From 41311592020ef68e9017f13e1cd038c72ef980fe Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 14:29:51 +0200 Subject: [PATCH 063/188] fix(share): enforce private presence boundaries --- .../skills/connect-share-prism-e2e/SKILL.md | 7 +- docs/connect-share.md | 7 +- share/AGENTS.md | 8 +- .../connect/share/ShareConnectionGateway.kt | 141 ++++++++++++++++++ .../friend/FriendControlChannelHandler.kt | 33 ++-- .../share/ShareConnectionGatewayTest.kt | 82 ++++++++++ .../v1_20_1/Minecraft12111LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../v1_21_1/Minecraft12111LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../v1_21_11/Minecraft12111LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../fabric/v26_2/Minecraft262LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../share/fabric/FriendRequestServer.kt | 16 +- .../share/fabric/ui/FriendsViewModel.kt | 9 +- .../connect/share/fabric/ui/ShareUiMessage.kt | 38 ++++- .../share/fabric/FriendRequestServerTest.kt | 79 ++++++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 61 +++++++- .../share/fabric/ui/ShareUiMessageTest.kt | 71 +++++++++ 24 files changed, 633 insertions(+), 63 deletions(-) create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 55750e771..59e80b409 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -138,8 +138,11 @@ normal pending request, host approval, and one-shot admission path. `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. A Connect endpoint fallback status or public - DNS response does not prove the integrated world is reachable. +- **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. diff --git a/docs/connect-share.md b/docs/connect-share.md index 780412803..58866ae1e 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -50,9 +50,10 @@ or blocking cannot be bypassed with an old attempt. - 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**. When a friend is on another server, **Show current server** can - also hide that server's name; the current singleplayer world name remains - visible while hosting. + **Privacy**. **Show current server or world** hides both multiplayer server + names and singleplayer world names. Raw Minecraft status is not treated as + presence: it is accepted only for a confirmed friend when online, playing, + and current-world visibility are all enabled. - Each friend can be set to **Ask Every Time**, **Auto-Accept**, or **Never Allow**. The default is Ask Every Time. - Removing a friend revokes future presence and admissions and is synchronized diff --git a/share/AGENTS.md b/share/AGENTS.md index 555c5ae53..24f7f4b9b 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -74,7 +74,7 @@ redesigned for Kotlin. `--offline ` is authoritative; editing `InstanceAccountId` while Prism runs is not, because Prism rewrites it. - Prove the flow in layers: mDNS discovery, authenticated friend activity, - Minecraft status, then a real login whose host log contains + Minecraft status when host privacy permits it, then a real login whose host log contains ` joined the game`. Control-plane reachability or a status response does not prove that the world is joinable. `dns-sd -B _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are @@ -86,6 +86,12 @@ redesigned for Kotlin. open a separate target for gameplay and keep that target alive until the Minecraft connection finishes. Never reuse the friend-control target for a status probe or login. +- Authenticated friend activity is the authority for visible online, playing, + world-name, and joinable state. Never promote raw Minecraft status into UI + presence without a matching privacy-filtered activity response. The gateway + rejects status for unknown peers and whenever online, playing, or the current + server/world name is hidden; login remains independently admissible so a + privacy-safe join request can still succeed. - 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`; otherwise friends see a world that cannot yet 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 index 6e57abf04..74197e00d 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -6,6 +6,9 @@ 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 @@ -21,6 +24,7 @@ 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 @@ -123,6 +127,10 @@ class ShareConnectionGateway private constructor( FRIEND_CONTROL_HANDLER, FriendControlChannelHandler(friendServer), ) + channel.pipeline().addLast( + MINECRAFT_STATUS_PRIVACY_HANDLER, + MinecraftStatusPrivacyHandler(friendServer), + ) channel.pipeline().addLast( MINECRAFT_DISPATCH_HANDLER, MinecraftDispatchHandler(activeMinecraft), @@ -137,6 +145,133 @@ class ShareConnectionGateway private constructor( 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?>, @@ -186,9 +321,15 @@ class ShareConnectionGateway private constructor( "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/friend/FriendControlChannelHandler.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt index 50af91947..bbbd7b4aa 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -47,6 +48,25 @@ fun interface FriendControlServer { 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( @@ -297,17 +317,6 @@ class FriendControlChannelHandler( } private fun ChannelHandlerContext.controlContext(): FriendControlContext { - val direct = channel() - .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(), - ) + return channel().friendControlContext() } } 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 index 80bae656c..4f6376fa6 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -2,8 +2,10 @@ 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 @@ -19,6 +21,7 @@ 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.TimeUnit import kotlin.test.Test import kotlin.test.assertContentEquals @@ -27,6 +30,77 @@ import kotlin.test.assertIs import kotlin.test.assertTrue class ShareConnectionGatewayTest { + @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() @@ -290,6 +364,14 @@ class ShareConnectionGatewayTest { } 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", 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 index cd65ba8ba..463750eb4 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -126,8 +126,8 @@ object Minecraft1201LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -169,10 +169,6 @@ object Minecraft1201LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index d9cb7576b..1ff90a1c3 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -127,8 +127,8 @@ object Minecraft1211LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -170,10 +170,6 @@ object Minecraft1211LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 50cb67665..a6235397d 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -127,8 +127,8 @@ object Minecraft12111LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -170,10 +170,6 @@ object Minecraft12111LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8820664b4..c7e5a3b32 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -127,8 +127,8 @@ object Minecraft262LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -170,10 +170,6 @@ object Minecraft262LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index f7096b43a..64346887a 100644 --- 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 @@ -48,6 +48,17 @@ class FriendRequestServer( }, private val joinTarget: () -> String? = { null }, ) : FriendControlServer { + override fun allowsMinecraftStatus( + context: FriendControlContext, + ): Boolean { + val friend = authenticatedFriend(context) ?: return false + val privacy = presencePrivacy() + return friend.permissions.canSeeMyWorlds && + privacy.showOnline && + privacy.showPlaying && + privacy.showCurrentServer + } + override fun handle( context: FriendControlContext, request: FriendControlRequest, @@ -126,7 +137,10 @@ class FriendRequestServer( else -> current.copy( description = current.description.takeIf { - current.kind != FriendActivityKind.PLAYING_SERVER || + ( + current.kind != FriendActivityKind.PLAYING_SERVER && + current.kind != FriendActivityKind.HOSTING_WORLD + ) || privacy.showCurrentServer }, joinable = current.joinable && privacy.showJoinable && 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 index 2a32d2391..e9c39237c 100644 --- 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 @@ -390,9 +390,9 @@ class FriendsViewModel( } private fun SavedFriend.summary(): FriendSummary { - val remote = remotePresence[peerId] - ?.takeIf { it.online } val activity = activities[peerId] + val remote = remotePresence[peerId] + ?.takeIf { it.online && activity != null } return FriendSummary( peerId = peerId, displayName = displayName, @@ -401,14 +401,13 @@ class FriendsViewModel( internetDirectGuestOptIn = internetDirectGuestOptIn, onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, onlineViaConnect = remote?.route == ShareRoute.CONNECT, - worldName = remote?.description, + worldName = activity?.description, activityKind = activity?.kind, activityDescription = activity?.description, canRequestJoin = activity?.joinable == true && ( activity.kind == FriendActivityKind.PLAYING_SERVER || - activity.kind == FriendActivityKind.HOSTING_WORLD && - remote != null + activity.kind == FriendActivityKind.HOSTING_WORLD ), canJoinNow = activity?.joinable == true && remote != null && 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 index 0af87934f..e5cd4beba 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -13,9 +15,41 @@ data class ShareUiMessage( val arguments: List = emptyList(), ) +object ShareLoginMessages { + const val AUTHENTICATION_REQUIRED = + "connect_share.login.authentication_required" + + fun denial(answer: AdmissionAnswer?): String = when (answer) { + AdmissionAnswer.TIMEOUT -> + "connect_share.login.approval_timed_out" + AdmissionAnswer.CAPACITY -> + "connect_share.login.share_full" + AdmissionAnswer.STOPPED -> + "connect_share.login.sharing_stopped" + else -> "connect_share.login.host_denied" + } +} + +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 -> - ShareUiMessage("connect_share.error.invalid_invitation") + reason.uiMessage() FriendStoreError.InvalidDisplayName -> ShareUiMessage("connect_share.error.invalid_friend_name") FriendStoreError.IdentityConflict -> @@ -48,7 +82,7 @@ fun ShareLifecycleError.uiMessage(): ShareUiMessage = when (this) { fun GuestJoinFailure.uiMessage(): ShareUiMessage = when (this) { is GuestJoinFailure.InvalidInvitation -> - ShareUiMessage("connect_share.error.invalid_invitation") + error.uiMessage() GuestJoinFailure.PeerMismatch -> ShareUiMessage("connect_share.error.join_peer_mismatch") GuestJoinFailure.DiscoveryUnavailable -> 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 index 933f65017..44ac662d3 100644 --- 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 @@ -473,6 +473,85 @@ class FriendRequestServerTest { ) } + @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(), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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 index c6829bc32..357f8a7eb 100644 --- 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 @@ -104,7 +104,6 @@ class FriendsViewModelTest { ), ), ) - assertTrue(viewModel.state.value.friends.isEmpty()) assertEquals( PEER_ID, @@ -281,6 +280,14 @@ class FriendsViewModelTest { ), ), ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Robin's New World", + ), + ), + ) val online = viewModel.state.value.friends.single() assertTrue(online.onlineViaLan) @@ -323,6 +330,14 @@ class FriendsViewModelTest { ), ), ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Robin's Remote World", + ), + ), + ) val online = viewModel.state.value.friends.single() assertTrue(online.onlineViaConnect) @@ -359,6 +374,31 @@ class FriendsViewModelTest { ) } + @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) @@ -459,6 +499,25 @@ class FriendsViewModelTest { 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() 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..93a536d3d --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt @@ -0,0 +1,71 @@ +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( + "connect_share.login.authentication_required", + ShareLoginMessages.AUTHENTICATION_REQUIRED, + ) + assertEquals( + "connect_share.login.approval_timed_out", + ShareLoginMessages.denial(AdmissionAnswer.TIMEOUT), + ) + assertEquals( + "connect_share.login.share_full", + ShareLoginMessages.denial(AdmissionAnswer.CAPACITY), + ) + assertEquals( + "connect_share.login.sharing_stopped", + ShareLoginMessages.denial(AdmissionAnswer.STOPPED), + ) + assertEquals( + "connect_share.login.host_denied", + ShareLoginMessages.denial(AdmissionAnswer.DENY), + ) + } +} From f7637874e3e3cbee2ff8d2c58708b23a5d0ff3ad Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 14:40:28 +0200 Subject: [PATCH 064/188] fix(share): keep visible status routes usable --- docs/connect-share.md | 5 +++-- share/AGENTS.md | 8 +++++--- .../share/fabric/FriendRequestServer.kt | 14 ++++++++----- .../share/fabric/FriendRequestServerTest.kt | 20 +++++++++++++++++++ 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/connect-share.md b/docs/connect-share.md index 58866ae1e..b2c7b4b00 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -52,8 +52,9 @@ or blocking cannot be bypassed with an old attempt. - Online, playing, and joinable state can each be hidden independently under **Privacy**. **Show current server or world** hides both multiplayer server names and singleplayer world names. Raw Minecraft status is not treated as - presence: it is accepted only for a confirmed friend when online, playing, - and current-world visibility are all enabled. + 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 Every Time**, **Auto-Accept**, or **Never Allow**. The default is Ask Every Time. - Removing a friend revokes future presence and admissions and is synchronized diff --git a/share/AGENTS.md b/share/AGENTS.md index 24f7f4b9b..776b8dca3 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -89,9 +89,11 @@ redesigned for Kotlin. - Authenticated friend activity is the authority for visible online, playing, world-name, and joinable state. Never promote raw Minecraft status into UI presence without a matching privacy-filtered activity response. The gateway - rejects status for unknown peers and whenever online, playing, or the current - server/world name is hidden; login remains independently admissible so a - privacy-safe join request can still succeed. + rejects status whenever online, playing, or the current server/world name is + hidden. A capability route may answer status only when all three are visible; + this must never promote an unknown or pending identity into social presence. + Login remains independently admissible so a privacy-safe join request can + still succeed. - 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`; otherwise friends see a world that cannot yet 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 index 64346887a..587a80d1c 100644 --- 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 @@ -51,12 +51,16 @@ class FriendRequestServer( override fun allowsMinecraftStatus( context: FriendControlContext, ): Boolean { - val friend = authenticatedFriend(context) ?: return false val privacy = presencePrivacy() - return friend.permissions.canSeeMyWorlds && - privacy.showOnline && - privacy.showPlaying && - privacy.showCurrentServer + 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( 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 index 44ac662d3..29e94c51a 100644 --- 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 @@ -552,6 +552,26 @@ class FriendRequestServerTest { ) } + @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, From 3e8d686e98576d65a4f6fde6ab7ea489477c8f82 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 15:18:51 +0200 Subject: [PATCH 065/188] no-mistakes(review): Hardened release, invitation, and identity lifecycle paths --- .github/workflows/connect-share-release.yml | 25 ++++++++----- .github/workflows/release-repair.yml | 15 +++++++- .github/workflows/release.yml | 2 +- .../connect/share/direct/ShareInviteCodec.kt | 7 +++- .../share/direct/ShareInviteCodecTest.kt | 17 +++++++++ .../share/fabric/DirectControlPlane.kt | 8 ++++ .../share/fabric/FabricShareBootstrap.kt | 1 + .../share/fabric/PersistentDirectIngress.kt | 15 ++++++++ .../share/fabric/DirectControlPlaneTest.kt | 37 +++++++++++++++++++ 9 files changed, 114 insertions(+), 13 deletions(-) diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml index c81d1193b..9a6e7cc91 100644 --- a/.github/workflows/connect-share-release.yml +++ b/.github/workflows/connect-share-release.yml @@ -29,8 +29,6 @@ jobs: RELEASE_TYPE: ${{ inputs.release_type }} MODRINTH_PROJECT_ID: ${{ vars.CONNECT_SHARE_MODRINTH_PROJECT_ID }} CURSEFORGE_PROJECT_ID: ${{ vars.CONNECT_SHARE_CURSEFORGE_PROJECT_ID }} - MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} - CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} steps: - name: Checkout release tag @@ -87,6 +85,17 @@ jobs: done sha256sum 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 }} @@ -96,15 +105,9 @@ jobs: gh release upload "$RELEASE_TAG" dist/*.jar dist/SHA256SUMS-connect-share.txt \ --repo "$GITHUB_REPOSITORY" --clobber - - name: Verify marketplace configuration - 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: Publish verified artifacts to Modrinth + env: + MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} run: | set -euo pipefail for spec in \ @@ -143,6 +146,8 @@ jobs: done - name: Publish verified artifacts to CurseForge + env: + CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} run: | set -euo pipefail for spec in \ 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 00e0c1d2e..7271985e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: - 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/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 index f40f4825c..06e47794d 100644 --- 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 @@ -121,6 +121,7 @@ object ShareInviteCodec { 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( @@ -337,7 +338,11 @@ object ShareInviteCodec { connectAddress = nullableText(), peerId = text(), internetDirectEnabled = bool(), - directCandidates = List(readLength(4)) { text() }, + directCandidates = List( + readLength(4).also { + require(it <= MAX_CANDIDATE_COUNT) + }, + ) { text() }, capability = text(), displayName = if (wireVersion == LEGACY_WIRE_VERSION) { null 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 index 66b97f635..ad65e8aba 100644 --- 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 @@ -136,6 +136,23 @@ class ShareInviteCodecTest { 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, 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 index d742f549a..bd86ef964 100644 --- 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 @@ -58,4 +58,12 @@ class DirectControlPlane( 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/FabricShareBootstrap.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt index fb90a2c12..8109f040a 100644 --- 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 @@ -262,6 +262,7 @@ object FabricShareBootstrap { ".play.minekube.net", ) startedControlPlane.restart() + startedDirectControlPlane.restart() }, startShare = coordinator::start, stopShare = coordinator::stop, 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 index 3ca607b8c..c13c68864 100644 --- 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 @@ -98,6 +98,21 @@ class PersistentDirectIngress( } } + 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, 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 index b23d97a9a..887dcbe46 100644 --- 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 @@ -12,6 +12,7 @@ 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 @@ -74,6 +75,40 @@ class DirectControlPlaneTest { 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) @@ -105,6 +140,7 @@ class DirectControlPlaneTest { private class RecordingIngress : DirectShareIngress { var starts = 0 var closes = 0 + val startedAddresses = mutableListOf() override suspend fun start( options: ShareOptions, @@ -112,6 +148,7 @@ class DirectControlPlaneTest { connectAddress: String?, ): DirectShareHandle { starts++ + startedAddresses += connectAddress return DirectShareHandle( invitation = "minekube://share/persistent-control", lanAvailable = true, From 1bb3cb5010556c7b729da608ced7a58b48c6b0c6 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 16:21:04 +0200 Subject: [PATCH 066/188] no-mistakes(test): Conditioned status probing on visible world privacy --- .../share/fabric/PrismFriendJoinE2ETest.kt | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) 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 index 54f9b5e11..3e3e79282 100644 --- 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 @@ -79,23 +79,28 @@ class PrismFriendJoinE2ETest { .FriendActivityRequest(UUID.randomUUID()), ) } - assertEquals( - FriendActivityKind.HOSTING_WORLD, - activityResult.getOrNull()?.kind - ?: fail(activityResult.leftOrNull()?.safeMessage - ?: "Host returned no friend activity"), - ) + val activity = activityResult.getOrNull() + ?: fail( + activityResult.leftOrNull()?.safeMessage + ?: "Host returned no friend activity", + ) + assertEquals(FriendActivityKind.HOSTING_WORLD, activity.kind) - // Status and gameplay require different one-shot proxies. - withTimeout(30_000) { - while ( - browser.probeLan( - friend, - DirectP2pAuthMode.OFFLINE, - MinecraftStatusProbe(), - ) == null - ) { - delay(250) + // A hidden world name intentionally rejects 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( From ac826aa0949e2fef599bc6b9107efd2672d8e51c Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 16:37:05 +0200 Subject: [PATCH 067/188] no-mistakes(document): Aligned E2E status guidance with privacy --- .agents/skills/connect-share-prism-e2e/SKILL.md | 9 ++++++--- docs/connect-share-testing.md | 7 +++++-- .../specs/2026-07-31-connect-share-prism-skill-design.md | 9 +++++---- .../connect/share/fabric/PrismFriendJoinE2ETest.kt | 6 +++--- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 59e80b409..e2f617b93 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -6,8 +6,9 @@ description: Drive and diagnose Connect Share with two real Prism Launcher clien # Connect Share Prism E2E Use the repository's opt-in live harness to prove the complete friend-to-world -flow. Treat discovery, activity, status, approval, and Minecraft login as -separate gates; success at an earlier gate never proves a later one. +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 @@ -84,7 +85,9 @@ The test must remain running while the external guest uses the port written to 1. mDNS discovers the saved confirmed friend's peer identity. 2. Authenticated friend control reports `HOSTING_WORLD`. -3. A dedicated direct proxy answers a real Minecraft status probe. +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 diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index f93c51c43..249445279 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -195,8 +195,11 @@ 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, status, approval, and a new ` joined the game` host-log -line. Startup or control-plane reachability alone does not pass. +friend activity, privacy-permitted status when the host exposes its world name, +approval, and a new ` joined the game` host-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 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 index 8451a5f22..25788fb9a 100644 --- 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 @@ -29,10 +29,11 @@ Keep `SKILL.md` concise and procedural. It will require agents to: 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, 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 because the current proxy - is one-shot. +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 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 index 3e3e79282..a8dbde9b8 100644 --- 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 @@ -86,9 +86,9 @@ class PrismFriendJoinE2ETest { ) assertEquals(FriendActivityKind.HOSTING_WORLD, activity.kind) - // A hidden world name intentionally rejects raw Minecraft - // status. Authenticated activity remains the privacy-safe - // authority, and gameplay admission is independent. + // 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) { From d60acaf118940ecad08c2272ad20ea282cea1c0c Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 17:07:48 +0200 Subject: [PATCH 068/188] fix(share): preserve friend routes across world discovery --- .../skills/connect-share-prism-e2e/SKILL.md | 4 ++ share/AGENTS.md | 5 ++ .../share/fabric/FabricShareBrowser.kt | 4 +- .../share/fabric/FabricShareBrowserTest.kt | 41 +++++++++++ .../share/fabric/PrismFriendJoinE2ETest.kt | 68 ++++++++++++++++--- 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index e2f617b93..eebe24afb 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -136,6 +136,10 @@ normal pending request, host approval, and one-shot admission path. - **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. + The social control peer and active-world peer share a stable share ID but + use different peer IDs, so browser discovery must retain entries by + `(shareId, peerId)`; retaining only the latest share ID makes friend status + and joins depend on mDNS event order. - **Runtime readiness:** use `jcmd GC.class_histogram` to look for `ShareState$Sharing`, `ActiveTransport`, `PublishedVanillaTransport`, and `ShareCoordinator$ActiveShare` when ordinary logs are insufficient. diff --git a/share/AGENTS.md b/share/AGENTS.md index 776b8dca3..7d27c7e59 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -128,6 +128,11 @@ redesigned for Kotlin. Prism instance copies `share-libp2p-identity.key`; simultaneously advertising that same peer identity from several processes makes mDNS routing ambiguous and can produce misleading libp2p stream failures. +- 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)`; deduplicating by share ID alone can + evict the saved friend's control route immediately after authenticated + activity and make status/join readiness appear flaky. - Manually constructed Prism Forge/NeoForge components need correct `cachedRequires` metadata and usually one online first launch to download loader libraries. Kotlin for Forge must be installed from its `-all.jar`; 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 index 70f77ea59..f0ab2f492 100644 --- 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 @@ -404,7 +404,9 @@ class FabricShareBrowser private constructor( ) mutableDiscovered.value = ( mutableDiscovered.value.filterNot { - it.invitation.payload.shareId == invitation.payload.shareId + val existing = it.invitation.payload + existing.shareId == invitation.payload.shareId && + existing.peerId == invitation.payload.peerId } + found ).takeLast(MAX_DISCOVERED_SHARES) } 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 index 4bc271b9d..9f7fb287e 100644 --- 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 @@ -144,6 +144,47 @@ class FabricShareBrowserTest { 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 `friend control uses saved direct internet route outside the LAN`() = runTest { val node = FakeGuestNode() 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 index a8dbde9b8..2b6a61b79 100644 --- 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 @@ -19,12 +19,35 @@ 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") + val absent = snapshotLog(guestLog) + Files.writeString( + guestLog, + "[old] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, absent)) + val before = snapshotLog(guestLog) + + Files.writeString( + guestLog, + "[new] [Render thread/INFO]: Loaded 41 advancements\n", + ) + + assertTrue(hasNewLoadedAdvancements(guestLog, before)) + } + @Test fun `saved friend requests and joins a live singleplayer world`() = runBlocking { @@ -44,7 +67,7 @@ class PrismFriendJoinE2ETest { val joinsBefore = Files.readString(hostLog) .lineSequence() .count { joinedLine in it } - val guestLoadsBefore = guestLog?.let(::loadedAdvancementsCount) + val guestLogBefore = guestLog?.let(::snapshotLog) val friend = FriendStore(dataDirectory).all().single() System.getenv("LIVE_HOST_DATA")?.let { hostDataValue -> val guestPeerId = DirectP2pNode( @@ -144,11 +167,13 @@ class PrismFriendJoinE2ETest { delay(100) } } - if (guestLog != null && guestLoadsBefore != null) { + if (guestLog != null && guestLogBefore != null) { withTimeout(180_000) { while ( - loadedAdvancementsCount(guestLog) <= - guestLoadsBefore + !hasNewLoadedAdvancements( + guestLog, + guestLogBefore, + ) ) { delay(100) } @@ -160,12 +185,35 @@ class PrismFriendJoinE2ETest { } } - private fun loadedAdvancementsCount(log: Path): Int = - if (Files.exists(log)) { - Files.readString(log).lineSequence().count { - "Loaded " in it && " advancements" in it - } + private fun snapshotLog(path: Path): LogSnapshot = + (if (Files.exists(path)) Files.readString(path) else "").let { contents -> + LogSnapshot( + contents = contents, + loadedAdvancements = loadedAdvancementsCount(contents), + ) + } + + private fun hasNewLoadedAdvancements( + path: Path, + before: LogSnapshot, + ): Boolean { + if (!Files.exists(path)) return false + val contents = Files.readString(path) + val current = loadedAdvancementsCount(contents) + return if (contents.startsWith(before.contents)) { + current > before.loadedAdvancements } else { - 0 + current > 0 } + } + + private fun loadedAdvancementsCount(contents: String): Int = + contents.lineSequence().count { + "Loaded " in it && " advancements" in it + } + + private data class LogSnapshot( + val contents: String, + val loadedAdvancements: Int, + ) } From 9f875070d78036c996c06b6c2cf75bd72b74b640 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 17:33:57 +0200 Subject: [PATCH 069/188] no-mistakes(review): Hardened concurrent discovery, mDNS refresh, and rotated-log evidence --- .../tunnel/p2p/DirectP2pNodeRuntime.java | 11 ++- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 22 +++++ .../share/fabric/FabricShareBrowser.kt | 17 ++-- .../share/fabric/FabricShareBrowserTest.kt | 48 +++++++++++ .../share/fabric/PrismFriendJoinE2ETest.kt | 86 +++++++++++++++---- 5 files changed, 156 insertions(+), 28 deletions(-) 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 index fc73f067a..f1682a3b0 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -69,6 +69,7 @@ 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; @@ -99,7 +100,7 @@ final class DirectP2pNodeRuntime { private final PrivKey privateKey; private final List proxies = new CopyOnWriteArrayList<>(); - private final java.util.Set discoveredInvitations = + private final Set discoveredInvitations = Collections.newSetFromMap(new ConcurrentHashMap<>()); private final java.util.Set mdnsInspections = Collections.newSetFromMap(new ConcurrentHashMap<>()); @@ -472,7 +473,7 @@ private void onMdnsPeer(PeerInfo peer) { try { DirectP2pDiscoveredShare found = inspect(address, Duration.ofSeconds(3)); - if (discoveredInvitations.add(found.invitation())) { + if (shouldNotifyDiscovery(discoveredInvitations, found)) { listener.onDiscovered(found); } return; @@ -485,6 +486,12 @@ private void onMdnsPeer(PeerInfo peer) { 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"); 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 index a58f6ab33..93082a98e 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -39,7 +39,9 @@ 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; @@ -319,6 +321,26 @@ void mdnsHostNameComesFromPeerIdentityWithoutDnsResolution() { 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(); 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 index f0ab2f492..e5afab5a5 100644 --- 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 @@ -27,6 +27,7 @@ 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( @@ -402,13 +403,15 @@ class FabricShareBrowser private constructor( invitation = invitation, lanAddress = discovered.address(), ) - mutableDiscovered.value = ( - mutableDiscovered.value.filterNot { - val existing = it.invitation.payload - existing.shareId == invitation.payload.shareId && - existing.peerId == invitation.payload.peerId - } + found - ).takeLast(MAX_DISCOVERED_SHARES) + 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( 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 index 9f7fb287e..7487232d0 100644 --- 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 @@ -18,6 +18,8 @@ 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 @@ -185,6 +187,37 @@ class FabricShareBrowserTest { 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() @@ -544,6 +577,21 @@ class FabricShareBrowserTest { 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, 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 index 2b6a61b79..b20be3149 100644 --- 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 @@ -5,12 +5,15 @@ 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.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.test.fail @@ -37,15 +40,34 @@ class PrismFriendJoinE2ETest { guestLog, "[old] [Render thread/INFO]: Loaded 41 advancements\n", ) + assertFalse(hasNewLoadedAdvancements(guestLog, absent)) + + Files.writeString( + guestLog, + "[old] [Render thread/INFO]: Loaded 41 advancements\n" + + "[new] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, absent)) - val before = snapshotLog(guestLog) Files.writeString( guestLog, - "[new] [Render thread/INFO]: Loaded 41 advancements\n", + "[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", + ) + assertFalse(hasNewLoadedAdvancements(guestLog, beforeRotation)) - assertTrue(hasNewLoadedAdvancements(guestLog, before)) + Files.writeString( + guestLog, + "[startup] [Render thread/INFO]: Loaded 41 advancements\n" + + "[join] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, beforeRotation)) } @Test @@ -186,25 +208,42 @@ class PrismFriendJoinE2ETest { } private fun snapshotLog(path: Path): LogSnapshot = - (if (Files.exists(path)) Files.readString(path) else "").let { contents -> - LogSnapshot( - contents = contents, - loadedAdvancements = loadedAdvancementsCount(contents), - ) - } + readLog(path) ?: LogSnapshot( + exists = false, + fileKey = null, + contents = "", + loadedAdvancements = 0, + ) private fun hasNewLoadedAdvancements( path: Path, before: LogSnapshot, ): Boolean { - if (!Files.exists(path)) return false - val contents = Files.readString(path) - val current = loadedAdvancementsCount(contents) - return if (contents.startsWith(before.contents)) { - current > before.loadedAdvancements - } else { - current > 0 + 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) + } + if (!sameFile || !current.contents.startsWith(before.contents)) { + before.replaceWith(current) + return false } + return current.loadedAdvancements > before.loadedAdvancements + } + + 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 = @@ -213,7 +252,16 @@ class PrismFriendJoinE2ETest { } private data class LogSnapshot( - val contents: String, - val loadedAdvancements: Int, - ) + var exists: Boolean, + var fileKey: Any?, + var contents: String, + var loadedAdvancements: Int, + ) { + fun replaceWith(other: LogSnapshot) { + exists = other.exists + fileKey = other.fileKey + contents = other.contents + loadedAdvancements = other.loadedAdvancements + } + } } From 844403097f36a336dddd8a211b830872e58e9f4f Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 18:12:48 +0200 Subject: [PATCH 070/188] no-mistakes(document): Consolidated Share docs; no lint issues remain --- .agents/skills/connect-share-prism-e2e/SKILL.md | 10 ++++++---- share/AGENTS.md | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index eebe24afb..40d5dfd1f 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -80,6 +80,10 @@ LIVE_PLAYER_NAME= \ --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel ``` +The harness tracks the active log file and resets its baseline when Prism +rotates `latest.log`; keep `LIVE_GUEST_LOG` pointed at that active path and +wait for a new advancement line after a rotation. + The test must remain running while the external guest uses the port written to `LIVE_PORT_FILE`. It proves, in order: @@ -136,10 +140,8 @@ normal pending request, host approval, and one-shot admission path. - **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. - The social control peer and active-world peer share a stable share ID but - use different peer IDs, so browser discovery must retain entries by - `(shareId, peerId)`; retaining only the latest share ID makes friend status - and joins depend on mDNS event order. + 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. diff --git a/share/AGENTS.md b/share/AGENTS.md index 7d27c7e59..e19bd38af 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -130,9 +130,11 @@ redesigned for Kotlin. and can produce misleading libp2p stream failures. - 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)`; deduplicating by share ID alone can - evict the saved friend's control route immediately after authenticated - activity and make status/join readiness appear flaky. + retain one entry per `(shareId, peerId)` and refresh that entry when the same + peer advertises a changed address; deduplicating by share ID alone or + suppressing same-invitation address changes can evict the saved friend's + control route immediately after authenticated activity and make status/join + readiness appear flaky. - Manually constructed Prism Forge/NeoForge components need correct `cachedRequires` metadata and usually one online first launch to download loader libraries. Kotlin for Forge must be installed from its `-all.jar`; From 9b043d39ae1a7df5beaf1da3444c0d36219b6c47 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 18:20:44 +0200 Subject: [PATCH 071/188] test(share): preserve rotated join evidence --- .../skills/connect-share-prism-e2e/SKILL.md | 7 +-- .../share/fabric/PrismFriendJoinE2ETest.kt | 50 ++++++++++--------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 40d5dfd1f..1f3d06ac0 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -80,9 +80,10 @@ LIVE_PLAYER_NAME= \ --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel ``` -The harness tracks the active log file and resets its baseline when Prism -rotates `latest.log`; keep `LIVE_GUEST_LOG` pointed at that active path and -wait for a new advancement line after a rotation. +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 port written to `LIVE_PORT_FILE`. It proves, in order: 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 index b20be3149..9520fa912 100644 --- 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 @@ -13,7 +13,6 @@ import java.nio.file.attribute.BasicFileAttributes 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.test.fail @@ -35,12 +34,11 @@ class PrismFriendJoinE2ETest { @Test fun `rotated guest log counts fresh advancement evidence`() { val guestLog = tempDir.resolve("latest.log") - val absent = snapshotLog(guestLog) Files.writeString( guestLog, "[old] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertFalse(hasNewLoadedAdvancements(guestLog, absent)) + val beforeAppend = snapshotLog(guestLog) Files.writeString( guestLog, @@ -48,7 +46,7 @@ class PrismFriendJoinE2ETest { "[new] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertTrue(hasNewLoadedAdvancements(guestLog, absent)) + assertTrue(hasNewLoadedAdvancements(guestLog, beforeAppend)) Files.writeString( guestLog, @@ -60,14 +58,25 @@ class PrismFriendJoinE2ETest { guestLog, "[startup] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertFalse(hasNewLoadedAdvancements(guestLog, beforeRotation)) + 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, - "[startup] [Render thread/INFO]: Loaded 41 advancements\n" + - "[join] [Render thread/INFO]: Loaded 41 advancements\n", + "[previous] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertTrue(hasNewLoadedAdvancements(guestLog, beforeRotation)) + 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 @@ -226,11 +235,11 @@ class PrismFriendJoinE2ETest { } else { current.contents.startsWith(before.contents) } - if (!sameFile || !current.contents.startsWith(before.contents)) { - before.replaceWith(current) - return false + return if (sameFile && current.contents.startsWith(before.contents)) { + current.loadedAdvancements > before.loadedAdvancements + } else { + current.loadedAdvancements > 0 } - return current.loadedAdvancements > before.loadedAdvancements } private fun readLog(path: Path): LogSnapshot? = try { @@ -252,16 +261,9 @@ class PrismFriendJoinE2ETest { } private data class LogSnapshot( - var exists: Boolean, - var fileKey: Any?, - var contents: String, - var loadedAdvancements: Int, - ) { - fun replaceWith(other: LogSnapshot) { - exists = other.exists - fileKey = other.fileKey - contents = other.contents - loadedAdvancements = other.loadedAdvancements - } - } + val exists: Boolean, + val fileKey: Any?, + val contents: String, + val loadedAdvancements: Int, + ) } From 6073f2f6101d86d38c71e517148725fd2c089c82 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 18:54:06 +0200 Subject: [PATCH 072/188] no-mistakes(document): Aligned Prism two-client evidence guidance --- docs/connect-share-testing.md | 9 +++++---- share/AGENTS.md | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 249445279..287e90b6f 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -196,10 +196,11 @@ 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 new ` joined the game` host-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. +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 diff --git a/share/AGENTS.md b/share/AGENTS.md index e19bd38af..ed47cc53d 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -74,9 +74,10 @@ redesigned for Kotlin. `--offline ` is authoritative; editing `InstanceAccountId` while Prism runs is not, because Prism rewrites it. - Prove the flow in layers: mDNS discovery, authenticated friend activity, - Minecraft status when host privacy permits it, then a real login whose host log contains - ` joined the game`. Control-plane reachability or a status response does - not prove that the world is joinable. `dns-sd -B + Minecraft status when host privacy permits it, then follow [the testing + guide](../docs/connect-share-testing.md) for the real two-client login + evidence gates. Control-plane reachability or a status response does not + prove that the world is joinable. `dns-sd -B _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are useful diagnostics for discovery and live `ShareState`/transport objects. - Run only one Gradle invocation at a time in a worktree. Concurrent test tasks @@ -119,9 +120,10 @@ redesigned for Kotlin. permission afterwards. Keep machine-specific instance paths and credentials in environment variables, never in committed tests or scripts. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, - supply `LIVE_DATA`, `LIVE_PORT_FILE`, and `LIVE_HOST_LOG`, then launch the - guest against the port written to `LIVE_PORT_FILE`. The test succeeds only - after the host logs a new ` joined the game` line. + then follow [the testing guide](../docs/connect-share-testing.md) for the + complete two-client launch and evidence gates. Keep machine-specific paths + in `LIVE_DATA`, `LIVE_PORT_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` + environment variables. - Invoke the live harness with `--rerun-tasks`. Its environment variables are intentionally not task inputs, so an up-to-date result is not live evidence. - Keep only one host and one guest identity active during a live run. Cloning a From 21866428c96c723063d511fae0245c577d56d2c0 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:09:58 +0200 Subject: [PATCH 073/188] docs(share): map universal party acceptance evidence --- docs/connect-share-adoption-evidence.md | 98 +++++++++ ...08-02-connect-share-adoption-foundation.md | 194 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 docs/connect-share-adoption-evidence.md create mode 100644 docs/plans/2026-08-02-connect-share-adoption-foundation.md diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md new file mode 100644 index 000000000..0c00f052a --- /dev/null +++ b/docs/connect-share-adoption-evidence.md @@ -0,0 +1,98 @@ +# 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. +- **Gap**: code or focused coverage is incomplete. The issue must remain open. + +## Evidence baseline + +- Commit under test: `6073f2f6101d86d38c71e517148725fd2c089c82`. +- 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. + +## #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 required | `AdmissionControllerTest` (`approved friend request authorizes exactly one following gameplay join`) and `FriendJoinOrchestratorTest` (`shared world opens gameplay only after approval`) | Record fresh two-client host/guest login evidence on the exact artifact | +| 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 | Gap | `CompatibilityProfile.fingerprint()`, filtered profile transport in `FriendControlWire`, and compatibility-before-approval ordering in `FriendJoinOrchestratorTest` | Add a focused wire-level assertion that the fingerprint is carried and validated before admission | +| 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 recovery screen | +| Copy or link matching Modrinth or CurseForge pack metadata | Gap | `LoadedCompatibilityProfileFactory` accepts safe HTTPS metadata and recognizes both platforms; only Modrinth has focused coverage | Add CurseForge and unsafe-link coverage, then prove the rendered copy/open action | +| 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 | + +## #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` documents **Copy server address** and adapter artifact vocabulary asserts the friends-first UI | Copy it on the exact host artifact and join from a profile without Connect Share | +| 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | +| 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 | Gap | invitation expiry and host-denial translation keys exist in `ShareUiMessageTest`; no focused no-mod assertion covers the complete distinction | Add no-mod admission outcome coverage and inspect the vanilla disconnect copy | + +## #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` (`offline reconnect with copied name requires a new approval`) | 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 | Gap | `FollowNextSessionControllerTest` covers expiry, cancellation, reconnect, removal through confirmed-set loss, duplicates, and simultaneous follow | Add an explicit blocked-relationship regression and verify the packaged cancel notification | + +## Open foundation gaps + +The baseline intentionally leaves #95, #96, #99, #100, and #103 open. The +next TDD slice starts with the three explicit automated gaps above, then uses +the exact-head Prism harness for product proof. Minecraft UI clicks are never +automated; any irreducible approval interaction is recorded as a human +checkpoint with all other evidence gathered noninteractively. 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..8b6bbc8af --- /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. + +- [ ] **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. From 9397658c11dfff381763492954e900b1a09ec57f Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:18:26 +0200 Subject: [PATCH 074/188] fix(share): make joins and follow cancellation actionable --- .../connect/share/friend/FriendControlWire.kt | 15 ++++++- .../share/friend/FriendControlWireTest.kt | 41 +++++++++++++++++ .../fabric/v1_20_1/ConnectShare12111Client.kt | 19 ++++---- .../v1_20_1/Minecraft12111LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../fabric/v1_21_1/ConnectShare12111Client.kt | 19 ++++---- .../v1_21_1/Minecraft12111LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../v1_21_11/ConnectShare12111Client.kt | 19 ++++---- .../v1_21_11/Minecraft12111LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../fabric/v26_2/ConnectShare262Client.kt | 19 ++++---- .../fabric/v26_2/Minecraft262LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../fabric/ui/ShareScreenPresentation.kt | 23 ++++++++++ .../connect/share/fabric/ui/ShareUiMessage.kt | 37 +++++++++++----- .../fabric/FollowNextSessionControllerTest.kt | 23 ++++++++++ .../LoadedCompatibilityProfileFactoryTest.kt | 44 +++++++++++++++++++ .../fabric/ui/ShareScreenPresentationTest.kt | 14 ++++++ .../share/fabric/ui/ShareUiMessageTest.kt | 25 ++++++++--- 24 files changed, 278 insertions(+), 60 deletions(-) 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 index 4ed1992e3..7807b1d6e 100644 --- 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 @@ -98,6 +98,7 @@ object FriendControlWire { 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 @@ -475,6 +476,7 @@ object FriendControlWire { 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) @@ -596,6 +598,15 @@ object FriendControlWire { } 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() @@ -620,12 +631,14 @@ object FriendControlWire { ) else -> invalid() } - return CompatibilityProfile( + val profile = CompatibilityProfile( minecraftVersion = minecraftVersion, loader = loader, requiredMods = mods, pack = pack, ) + ensure(profile.fingerprint() == expectedFingerprint) + return profile } fun ensure(condition: Boolean) { 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 index 22846f7d1..87b5a9fee 100644 --- 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 @@ -6,6 +6,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertTrue class FriendControlWireTest { @Test @@ -88,6 +89,38 @@ class FriendControlWireTest { } } + @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) @@ -190,6 +223,14 @@ class FriendControlWireTest { 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) 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 index 3a5a0a85f..a58bf53cb 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -371,14 +372,16 @@ class ConnectShare1201Runtime( action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index 463750eb4..e450b370b 100644 --- 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 @@ -126,8 +126,8 @@ object Minecraft1201LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -170,5 +170,5 @@ object Minecraft1201LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index ddd260c03..92c4755c8 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -371,14 +372,16 @@ class ConnectShare1211Runtime( action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index 1ff90a1c3..c21fd54d4 100644 --- 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 @@ -127,8 +127,8 @@ object Minecraft1211LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -171,5 +171,5 @@ object Minecraft1211LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index d2cd5b0ed..999123815 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -387,14 +388,16 @@ class ConnectShare12111Client : ClientModInitializer { action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index a6235397d..a2ac1224d 100644 --- 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 @@ -127,8 +127,8 @@ object Minecraft12111LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -171,5 +171,5 @@ object Minecraft12111LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index 6b7a47a28..be982c1b8 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -387,14 +388,16 @@ class ConnectShare262Client : ClientModInitializer { action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index c7e5a3b32..870c927e4 100644 --- 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 @@ -127,8 +127,8 @@ object Minecraft262LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -171,5 +171,5 @@ object Minecraft262LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index 61e947b26..7f995d736 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -57,6 +58,28 @@ data class CompatibilityLine( 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 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 index e5cd4beba..c60e50432 100644 --- 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 @@ -15,18 +15,35 @@ data class ShareUiMessage( val arguments: List = emptyList(), ) +data class RemoteLoginMessage( + val translationKey: String, + val fallback: String, +) + object ShareLoginMessages { - const val AUTHENTICATION_REQUIRED = - "connect_share.login.authentication_required" + val AUTHENTICATION_REQUIRED = RemoteLoginMessage( + "connect_share.login.authentication_required", + "This connection needs a valid Minecraft account.", + ) - fun denial(answer: AdmissionAnswer?): String = when (answer) { - AdmissionAnswer.TIMEOUT -> - "connect_share.login.approval_timed_out" - AdmissionAnswer.CAPACITY -> - "connect_share.login.share_full" - AdmissionAnswer.STOPPED -> - "connect_share.login.sharing_stopped" - else -> "connect_share.login.host_denied" + 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.", + ) } } 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 index ea9e961a9..c94cbec91 100644 --- 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 @@ -102,6 +102,29 @@ class FollowNextSessionControllerTest { 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 }) 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 index 8defd857e..05f7e3757 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -46,4 +47,47 @@ class LoadedCompatibilityProfileFactoryTest { 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/ui/ShareScreenPresentationTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt index 418718cc0..d927958ac 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -167,6 +168,19 @@ class ShareScreenPresentationTest { 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, 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 index 93a536d3d..f5522815f 100644 --- 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 @@ -48,23 +48,38 @@ class ShareUiMessageTest { @Test fun `login denial messages are stable translation keys`() { assertEquals( - "connect_share.login.authentication_required", + RemoteLoginMessage( + "connect_share.login.authentication_required", + "This connection needs a valid Minecraft account.", + ), ShareLoginMessages.AUTHENTICATION_REQUIRED, ) assertEquals( - "connect_share.login.approval_timed_out", + RemoteLoginMessage( + "connect_share.login.approval_timed_out", + "The host did not approve this join in time. Try again.", + ), ShareLoginMessages.denial(AdmissionAnswer.TIMEOUT), ) assertEquals( - "connect_share.login.share_full", + RemoteLoginMessage( + "connect_share.login.share_full", + "This shared world is full. Ask the host to make room.", + ), ShareLoginMessages.denial(AdmissionAnswer.CAPACITY), ) assertEquals( - "connect_share.login.sharing_stopped", + 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( - "connect_share.login.host_denied", + RemoteLoginMessage( + "connect_share.login.host_denied", + "The host declined this join. Request access again when ready.", + ), ShareLoginMessages.denial(AdmissionAnswer.DENY), ) } From 1aa421a431e9f763beaa658a61cebb141846e155 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:19:04 +0200 Subject: [PATCH 075/188] docs(share): record foundation gap fixes --- docs/connect-share-adoption-evidence.md | 29 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 0c00f052a..c70165410 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -19,13 +19,21 @@ Status meanings: ## Evidence baseline -- Commit under test: `6073f2f6101d86d38c71e517148725fd2c089c82`. +- Original acceptance-audit commit: + `6073f2f6101d86d38c71e517148725fd2c089c82`. +- Current deterministic head: + `9397658c11dfff381763492954e900b1a09ec57f`. - 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. ## #95 — one-click presence, request, approval, and join @@ -44,11 +52,11 @@ Status meanings: | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| -| Exchange a privacy-safe compatibility fingerprint before admission | Gap | `CompatibilityProfile.fingerprint()`, filtered profile transport in `FriendControlWire`, and compatibility-before-approval ordering in `FriendJoinOrchestratorTest` | Add a focused wire-level assertion that the fingerprint is carried and validated before admission | +| 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 recovery screen | -| Copy or link matching Modrinth or CurseForge pack metadata | Gap | `LoadedCompatibilityProfileFactory` accepts safe HTTPS metadata and recognizes both platforms; only Modrinth has focused coverage | Add CurseForge and unsafe-link coverage, then prove the rendered copy/open action | +| 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 | @@ -62,7 +70,7 @@ Status meanings: | 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | | 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 | Gap | invitation expiry and host-denial translation keys exist in `ShareUiMessageTest`; no focused no-mod assertion covers the complete distinction | Add no-mod admission outcome coverage and inspect the vanilla disconnect copy | +| Errors distinguish unavailable host from invalid or expired admission | Product proof required | `RemoteLoginMessage` gives vanilla-readable fallback text for stopped, timed-out, full, denied, and invalid-auth cases; `ShareUiMessageTest` fixes their distinct contracts | Inspect each fallback on a vanilla Direct Connect client | ## #100 — privacy, permissions, and relationship safety @@ -87,12 +95,13 @@ Status meanings: | 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 | Gap | `FollowNextSessionControllerTest` covers expiry, cancellation, reconnect, removal through confirmed-set loss, duplicates, and simultaneous follow | Add an explicit blocked-relationship regression and verify the packaged cancel notification | +| 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 | ## Open foundation gaps -The baseline intentionally leaves #95, #96, #99, #100, and #103 open. The -next TDD slice starts with the three explicit automated gaps above, then uses -the exact-head Prism harness for product proof. Minecraft UI clicks are never -automated; any irreducible approval interaction is recorded as a human -checkpoint with all other evidence gathered noninteractively. +The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the +remaining exact-head product claims are observed. The deterministic gaps found +in the first audit are fixed in `9397658c`; the next step is the Prism product +pass. Minecraft UI clicks are never automated; any irreducible approval +interaction is recorded as a human checkpoint with all other evidence gathered +noninteractively. From 73f306ff84fbf0e8d24426945e6cfd813cc14301 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:32:47 +0200 Subject: [PATCH 076/188] fix(share): preserve actionable no-mod admission errors --- .../skills/connect-share-prism-e2e/SKILL.md | 6 ++++ share/AGENTS.md | 4 +++ .../fabric/FabricSessionAdmissionGate.kt | 25 +++++++++++--- .../fabric/FabricSessionAdmissionGateTest.kt | 33 ++++++++++++++++++- .../fabric/FriendPairingDirectE2ETest.kt | 7 ++-- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 1f3d06ac0..ae26a353f 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -169,6 +169,12 @@ Recognize these established failure signatures: - 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. ## Finish and retain knowledge diff --git a/share/AGENTS.md b/share/AGENTS.md index ed47cc53d..cf213164e 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -119,6 +119,10 @@ redesigned for Kotlin. the confirmed test friend, send the real libp2p join request, and restore the permission afterwards. Keep machine-specific instance paths and credentials in environment variables, never in committed tests or scripts. +- Connect's no-mod session admission must finish before vanilla's own + connection timeout. Preserve a deadline buffer, cancel the pending host + request when it expires, and test the guest-visible actionable denial; + generic `Timed out` is a failed UX result. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, then follow [the testing guide](../docs/connect-share-testing.md) for the complete two-client launch and evidence gates. Keep machine-specific paths 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 index a3176ce52..7f143da68 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -16,11 +17,14 @@ 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, @@ -28,10 +32,17 @@ class FabricSessionAdmissionGate( 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 { @@ -62,7 +73,9 @@ class FabricSessionAdmissionGate( lateinit var job: Job job = scope.launch(start = CoroutineStart.LAZY) { try { - val answer = admission.request(identity) + val answer = withTimeoutOrNull(decisionTimeout) { + admission.request(identity) + } ?: AdmissionAnswer.TIMEOUT approvedJoins.record(identity, answer) future.complete(answer.toCoreDecision()) } catch (cancellation: CancellationException) { @@ -133,10 +146,12 @@ class FabricSessionAdmissionGate( private fun AdmissionAnswer.toCoreDecision(): SessionAdmissionDecision = when (this) { AdmissionAnswer.ALLOW -> SessionAdmissionDecision.allow() - AdmissionAnswer.DENY -> SessionAdmissionDecision.deny("Host denied this connection") - AdmissionAnswer.TIMEOUT -> SessionAdmissionDecision.deny("Host approval timed out") - AdmissionAnswer.STOPPED -> SessionAdmissionDecision.deny("Sharing stopped") - AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny("Share is full") + AdmissionAnswer.DENY, + AdmissionAnswer.TIMEOUT, + AdmissionAnswer.STOPPED, + AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny( + ShareLoginMessages.denial(this).fallback, + ) } private companion object { 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 index a9a0f6fe9..be1defab6 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -134,9 +135,39 @@ class FabricSessionAdmissionGateTest { val decision = result.getNow(null) assertFalse(decision.isAllowed) assertFalse(decision.isDeferredToLocalLogin) - assertEquals("Host denied this connection", decision.safeMessage) + 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() 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 index 0634266a4..30729ce9b 100644 --- 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 @@ -37,6 +37,7 @@ 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 @@ -134,7 +135,7 @@ class FriendPairingDirectE2ETest { now = { now }, ioDispatcher = Dispatchers.IO, ) - var received = false + val received = CompletableDeferred() val result = async { pairing.send( invitation = direct.invitation, @@ -152,7 +153,7 @@ class FriendPairingDirectE2ETest { DirectP2pAuthMode.OFFLINE, ) }, - onReceived = { received = true }, + onReceived = { received.complete(Unit) }, ) } @@ -161,7 +162,7 @@ class FriendPairingDirectE2ETest { .first { it.isNotEmpty() } .single() } - assertTrue(received) + withTimeout(5.seconds) { received.await() } admission.answer(pending.requestId, allow = true) assertTrue(result.await().isRight()) From 9805031d706ecd34a64029cc14a93206312c7382 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:40:03 +0200 Subject: [PATCH 077/188] test(share): record exact-head Prism evidence --- docs/connect-share-adoption-evidence.md | 33 +++++++++++++++---- ...08-02-connect-share-adoption-foundation.md | 2 +- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index c70165410..5e5b589c5 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -15,14 +15,16 @@ Status meanings: - **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`. -- Current deterministic head: - `9397658c11dfff381763492954e900b1a09ec57f`. +- Current source head for product probes: + `73f306ff84fbf0e8d24426945e6cfd813cc14301`. - 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`. @@ -34,6 +36,22 @@ Status meanings: `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. ## #95 — one-click presence, request, approval, and join @@ -43,7 +61,7 @@ Status meanings: | 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 required | `AdmissionControllerTest` (`approved friend request authorizes exactly one following gameplay join`) and `FriendJoinOrchestratorTest` (`shared world opens gameplay only after approval`) | Record fresh two-client host/guest login evidence on the exact artifact | +| 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 | @@ -64,13 +82,13 @@ Status meanings: | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| -| Host copies a short ordinary Minecraft server address | Product proof required | `docs/connect-share.md` documents **Copy server address** and adapter artifact vocabulary asserts the friends-first UI | Copy it on the exact host artifact and join from a profile without Connect Share | +| 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 reached Connecting through the ordinary public address | Inspect the copy action, then resolve the external Connect forwarding boundary and complete a vanilla join | | 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | | 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 required | `RemoteLoginMessage` gives vanilla-readable fallback text for stopped, timed-out, full, denied, and invalid-auth cases; `ShareUiMessageTest` fixes their distinct contracts | Inspect each fallback on a vanilla Direct Connect client | +| Errors distinguish unavailable host from invalid or expired admission | Product proof required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; the first product probe reproduced generic `Timed out` and drove the fix | The Connect edge must deliver a session before the rebuilt denial can be observed on vanilla | ## #100 — privacy, permissions, and relationship safety @@ -101,7 +119,8 @@ Status meanings: The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the remaining exact-head product claims are observed. The deterministic gaps found -in the first audit are fixed in `9397658c`; the next step is the Prism product -pass. Minecraft UI clicks are never automated; any irreducible approval +in the first audit are fixed in `9397658c`; the direct Prism join is proven and +the no-mod attempt is now blocked specifically at external Connect session +forwarding. Minecraft UI clicks are never automated; any irreducible approval interaction is recorded as a human checkpoint with all other evidence gathered noninteractively. diff --git a/docs/plans/2026-08-02-connect-share-adoption-foundation.md b/docs/plans/2026-08-02-connect-share-adoption-foundation.md index 8b6bbc8af..a96ce1936 100644 --- a/docs/plans/2026-08-02-connect-share-adoption-foundation.md +++ b/docs/plans/2026-08-02-connect-share-adoption-foundation.md @@ -117,7 +117,7 @@ git commit -m "docs(share): map universal party acceptance evidence" - 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. -- [ ] **Step 1: Build and hash the exact artifact** +- [x] **Step 1: Build and hash the exact artifact** Run: From 350980ff2a56b1a24e7380ebcdbecca30a6ea662 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:57:27 +0200 Subject: [PATCH 078/188] feat(share): add encrypted social recovery archive --- ...-08-02-connect-share-encrypted-recovery.md | 66 +++ .../connect/share/recovery/RecoveryArchive.kt | 346 +++++++++++++ .../connect/share/recovery/RecoveryStore.kt | 468 ++++++++++++++++++ .../share/recovery/RecoveryArchiveTest.kt | 154 ++++++ .../share/recovery/RecoveryStoreTest.kt | 252 ++++++++++ 5 files changed, 1286 insertions(+) create mode 100644 docs/plans/2026-08-02-connect-share-encrypted-recovery.md create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryArchive.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryArchiveTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt 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..1668f85a9 --- /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` + +- [ ] 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. +- [ ] 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. +- [ ] Run file/crypto work on IO dispatchers; render only typed summaries and safe localizable errors. +- [ ] 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. +- [ ] 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 + +- [ ] 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/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..1589e3eb3 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt @@ -0,0 +1,468 @@ +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 encrypted = try { + if ( + Files.isSymbolicLink(source) || + !Files.isRegularFile(source, NOFOLLOW_LINKS) || + Files.size(source) > RecoveryArchive.MAX_ARCHIVE_BYTES + ) { + return@synchronized RecoveryStoreError.BackupReadFailed.left() + } + Files.readAllBytes(source) + } catch (_: IOException) { + return@synchronized RecoveryStoreError.BackupReadFailed.left() + } catch (_: SecurityException) { + return@synchronized RecoveryStoreError.BackupReadFailed.left() + } + + val entries = try { + val decrypted = archive.decrypt(encrypted, passphrase) + val failure = decrypted.leftOrNull() + if (failure != null) { + return@synchronized RecoveryStoreError.ArchiveFailure(failure).left() + } + decrypted.getOrNull()!! + } finally { + encrypted.fill(0) + } + + 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) } + } + } + + 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) + 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/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..aa3063045 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt @@ -0,0 +1,252 @@ +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 `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 `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() + } +} From 1586cb6434890cec54c1c533151c9435154c8fd3 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:15:59 +0200 Subject: [PATCH 079/188] feat(share): add safe friend backup UX --- .../skills/connect-share-prism-e2e/SKILL.md | 8 + docs/connect-share-adoption-evidence.md | 21 +- docs/connect-share.md | 32 ++ ...-08-02-connect-share-encrypted-recovery.md | 12 +- share/AGENTS.md | 6 + .../connect/share/recovery/RecoveryStore.kt | 76 ++-- .../share/recovery/RecoveryStoreTest.kt | 34 ++ .../share/fabric/v1_20_1/RecoveryScreen.kt | 275 +++++++++++++++ .../fabric/v1_20_1/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/v1_21_1/RecoveryScreen.kt | 275 +++++++++++++++ .../fabric/v1_21_1/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/v1_21_11/RecoveryScreen.kt | 276 +++++++++++++++ .../fabric/v1_21_11/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/v26_2/RecoveryScreen.kt | 276 +++++++++++++++ .../share/fabric/v26_2/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/ConnectShareClient.kt | 7 + .../share/fabric/FabricShareBootstrap.kt | 14 + .../fabric/recovery/RecoveryViewModel.kt | 325 ++++++++++++++++++ .../fabric/recovery/RecoveryViewModelTest.kt | 215 ++++++++++++ 27 files changed, 2101 insertions(+), 51 deletions(-) create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/RecoveryScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/RecoveryScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/RecoveryScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/RecoveryScreen.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModel.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModelTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index ae26a353f..be34cb0b6 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -176,6 +176,14 @@ Recognize these established failure signatures: 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: diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 5e5b589c5..3c1eb4096 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -52,6 +52,14 @@ Status meanings: `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. +- 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. ## #95 — one-click presence, request, approval, and join @@ -73,7 +81,7 @@ Status meanings: | 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 recovery screen | +| 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 | @@ -115,6 +123,17 @@ Status meanings: | 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 | +## #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 diff --git a/docs/connect-share.md b/docs/connect-share.md index b2c7b4b00..2d6fbc2d6 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -79,6 +79,38 @@ 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 diff --git a/docs/plans/2026-08-02-connect-share-encrypted-recovery.md b/docs/plans/2026-08-02-connect-share-encrypted-recovery.md index 1668f85a9..2d2d1cbb7 100644 --- a/docs/plans/2026-08-02-connect-share-encrypted-recovery.md +++ b/docs/plans/2026-08-02-connect-share-encrypted-recovery.md @@ -48,11 +48,11 @@ - Modify: each supported Fabric settings/friends adapter and `en_us.json`/`de_de.json` - Modify: `docs/connect-share.md` -- [ ] 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. -- [ ] 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. -- [ ] Run file/crypto work on IO dispatchers; render only typed summaries and safe localizable errors. -- [ ] 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. -- [ ] 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. +- [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 @@ -60,7 +60,7 @@ - Modify: `docs/connect-share-adoption-evidence.md` - Modify: `.agents/skills/connect-share-prism-e2e/SKILL.md` and `share/AGENTS.md` only for reusable discoveries -- [ ] Build all supported artifacts and assert recovery strings/entrypoints are packaged where applicable. +- [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/share/AGENTS.md b/share/AGENTS.md index cf213164e..f773cac47 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -154,3 +154,9 @@ redesigned for Kotlin. persistent label; split pause-menu buttons must keep copy within their 100-pixel logical width. The repository Prism skill owns the capture and focus-order procedure. +- Recovery export/import must run only against the fixed Share allowlist and + while sharing is stopped. A selected 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 test simulated interruption. Never + print archive paths, contents, passwords, identities, or tokens as evidence. 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 index 1589e3eb3..78fbf07ab 100644 --- 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 @@ -104,30 +104,9 @@ class RecoveryStore( return@synchronized RecoveryStoreError.UnsafeMaterial.left() } - val encrypted = try { - if ( - Files.isSymbolicLink(source) || - !Files.isRegularFile(source, NOFOLLOW_LINKS) || - Files.size(source) > RecoveryArchive.MAX_ARCHIVE_BYTES - ) { - return@synchronized RecoveryStoreError.BackupReadFailed.left() - } - Files.readAllBytes(source) - } catch (_: IOException) { - return@synchronized RecoveryStoreError.BackupReadFailed.left() - } catch (_: SecurityException) { - return@synchronized RecoveryStoreError.BackupReadFailed.left() - } - - val entries = try { - val decrypted = archive.decrypt(encrypted, passphrase) - val failure = decrypted.leftOrNull() - if (failure != null) { - return@synchronized RecoveryStoreError.ArchiveFailure(failure).left() - } - decrypted.getOrNull()!! - } finally { - encrypted.fill(0) + val entries = when (val loaded = readArchiveEntries(source, passphrase)) { + is Either.Left -> return@synchronized loaded + is Either.Right -> loaded.value } try { @@ -145,6 +124,47 @@ class RecoveryStore( } } + 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 { @@ -255,6 +275,14 @@ class RecoveryStore( 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") } 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 index aa3063045..d5d01f48a 100644 --- 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 @@ -63,6 +63,19 @@ class RecoveryStoreTest { ) } + @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() @@ -117,6 +130,27 @@ class RecoveryStoreTest { 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() 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/SharePrivacyScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt index c49e7a8c5..d921fd2ae 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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/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/SharePrivacyScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt index 7816eed26..29264408b 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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/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/SharePrivacyScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt index 61cd7199b..a56ffd107 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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/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/SharePrivacyScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt index 4a04dd306..deac19b50 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft.gui.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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-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 index 901231dfd..1a1bf347b 100644 --- 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 @@ -6,6 +6,7 @@ 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) @@ -22,6 +23,7 @@ fun interface ConnectShareGuestScreenFactory { data class ConnectShareInstallation( val viewModel: ShareViewModel, val friendsViewModel: FriendsViewModel, + val recoveryViewModel: RecoveryViewModel, val runtime: ConnectShareRuntime, val friendCardIssuer: FriendCardIssuer, val friendCardReceiver: FriendCardReceiver, @@ -123,6 +125,10 @@ object ConnectShareClient { fun friendsViewModel(): FriendsViewModel = checkNotNull(installation).friendsViewModel + @JvmStatic + fun recoveryViewModel(): RecoveryViewModel = + checkNotNull(installation).recoveryViewModel + @JvmStatic fun friendCardIssuer(): FriendCardIssuer = checkNotNull(installation).friendCardIssuer @@ -178,6 +184,7 @@ object ConnectShareClient { friendCardConsent.cancel() guestLease.close() installation?.let { installed -> + installed.recoveryViewModel.close() installed.runtime.shutdown() installed.directControlPlane.shutdown() installed.controlPlane.shutdown() 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 index 8109f040a..232215398 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,8 @@ 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 @@ -22,6 +25,7 @@ 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 @@ -316,6 +320,15 @@ object FabricShareBootstrap { } }, ) + val recoveryViewModel = RecoveryViewModel( + scope = scope, + actions = StoredRecoveryUiActions( + RecoveryStore(dataDirectory), + ), + restoreAllowed = { + viewModel.state.value.shareState is ShareState.Idle + }, + ) val activityMonitor = FriendActivityMonitor( store = friendStore, query = { friend -> @@ -367,6 +380,7 @@ object FabricShareBootstrap { return ConnectShareInstallation( viewModel = viewModel, friendsViewModel = friendsViewModel, + recoveryViewModel = recoveryViewModel, runtime = runtime, friendCardIssuer = friendCardIssuer, friendCardReceiver = friendCardReceiver, 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/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, + ) + } +} From fc496fda3fe1d973d1a2b4df73cc8b34745b5767 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:39:23 +0200 Subject: [PATCH 080/188] build(share): reduce packaged runtime size safely --- .../connect.shadow-conventions.gradle.kts | 4 + share/AGENTS.md | 7 ++ share/fabric-1.20.1/build.gradle.kts | 4 +- share/fabric-1.21.1/build.gradle.kts | 4 +- share/fabric-1.21.11/build.gradle.kts | 4 +- share/fabric-26.2/build.gradle.kts | 4 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 114 +++++++++++++++++- share/forge-1.20.1/build.gradle.kts | 4 +- share/neoforge-1.21.1/build.gradle.kts | 4 +- 9 files changed, 136 insertions(+), 13 deletions(-) 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 64d66a52d..bd54037f9 100644 --- a/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts @@ -58,6 +58,10 @@ tasks { "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") { diff --git a/share/AGENTS.md b/share/AGENTS.md index f773cac47..429ad2e9e 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -160,3 +160,10 @@ redesigned for Kotlin. and decrypt the entire archive before replacement, keep rollback material until a committed marker is durable, and test simulated interruption. 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 through reflection and DSL entry points that static minimization does + not see. Any payload-size reduction must keep cross-platform natives and be + proved by constructing, starting, publishing, and inspecting between two + peers loaded from the exact packaged artifact. A constructor-only classloader + test is insufficient. diff --git a/share/fabric-1.20.1/build.gradle.kts b/share/fabric-1.20.1/build.gradle.kts index 1ff9e8802..c945f99d9 100644 --- a/share/fabric-1.20.1/build.gradle.kts +++ b/share/fabric-1.20.1/build.gradle.kts @@ -154,9 +154,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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.21.1/build.gradle.kts b/share/fabric-1.21.1/build.gradle.kts index 8b962a75f..02b516616 100644 --- a/share/fabric-1.21.1/build.gradle.kts +++ b/share/fabric-1.21.1/build.gradle.kts @@ -152,9 +152,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 2f992cacb..21e627bd6 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -151,9 +151,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 1bc566ca0..0334c3fca 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -152,9 +152,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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/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 index a31c88553..4d624a02b 100644 --- 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 @@ -5,9 +5,11 @@ 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.net.URLClassLoader +import java.time.Duration import java.util.jar.JarInputStream import java.util.jar.JarFile import kotlin.io.path.name @@ -159,6 +161,13 @@ class Fabric262ArtifactTest { 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, @@ -166,6 +175,17 @@ class Fabric262ArtifactTest { } } + @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 -> @@ -259,6 +279,97 @@ class Fabric262ArtifactTest { } } + @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( @@ -307,6 +418,7 @@ class Fabric262ArtifactTest { } private companion object { + const val MAX_ARTIFACT_BYTES = 63L * 1024L * 1024L val FORBIDDEN_TYPE_PREFIXES = listOf( "io.libp2p.", "io.netty.", diff --git a/share/forge-1.20.1/build.gradle.kts b/share/forge-1.20.1/build.gradle.kts index ed8606e95..81bf3bc5b 100644 --- a/share/forge-1.20.1/build.gradle.kts +++ b/share/forge-1.20.1/build.gradle.kts @@ -210,9 +210,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + val limit = 63L * 1024L * 1024L check(bytes <= limit) { - "Connect Share Forge 1.20.1 exceeds the 90 MiB release budget ($bytes bytes)" + "Connect Share Forge 1.20.1 exceeds the 63 MiB release budget ($bytes bytes)" } } } diff --git a/share/neoforge-1.21.1/build.gradle.kts b/share/neoforge-1.21.1/build.gradle.kts index dd8ac965f..1d5290107 100644 --- a/share/neoforge-1.21.1/build.gradle.kts +++ b/share/neoforge-1.21.1/build.gradle.kts @@ -172,9 +172,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + val limit = 63L * 1024L * 1024L check(bytes <= limit) { - "Connect Share NeoForge 1.21.1 exceeds the 90 MiB release budget ($bytes bytes)" + "Connect Share NeoForge 1.21.1 exceeds the 63 MiB release budget ($bytes bytes)" } } } From 81ac77b0244db0e6b29abc97559f641f2e935710 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:40:19 +0200 Subject: [PATCH 081/188] docs(share): record distribution artifact evidence --- docs/connect-share-adoption-evidence.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 3c1eb4096..3710d76d8 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -24,7 +24,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. - Current source head for product probes: - `73f306ff84fbf0e8d24426945e6cfd813cc14301`. + `fc496fda3fe1d973d1a2b4df73cc8b34745b5767`. - 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`. @@ -60,6 +60,14 @@ Status meanings: 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. ## #95 — one-click presence, request, approval, and join @@ -86,6 +94,18 @@ Status meanings: | 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 | Deterministic proof | Fabric 1.20.1/1.21.1/1.21.11/26.2, Forge 1.20.1, and NeoForge 1.21.1 all built and passed packaged artifact tests | Real-client startup and join evidence remains required for every release target | +| Publish verified artifacts on Modrinth, CurseForge, and GitHub Releases | Gap | Artifacts have unambiguous loader/version archive names and deterministic local verification | Marketplace projects, credentials, signing/release workflow, public metadata, 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 package KotlinForForge in their distributable 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 | + ## #99 — let friends join without installing the mod | Acceptance criterion | Status | Evidence | Remaining proof | From 4c9e3b6d0682802b63c1f7e02ad60531f149e159 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:55:53 +0200 Subject: [PATCH 082/188] test(share): record clean-head friend join proof --- docs/connect-share-adoption-evidence.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 3710d76d8..f01e0ec49 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -24,7 +24,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. - Current source head for product probes: - `fc496fda3fe1d973d1a2b4df73cc8b34745b5767`. + `81ac77b0244db0e6b29abc97559f641f2e935710`. - 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`. @@ -68,6 +68,14 @@ Status meanings: 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. ## #95 — one-click presence, request, approval, and join From 20e439b6de923b6731389ee0b833e8905b5008a8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 01:05:50 +0200 Subject: [PATCH 083/188] docs(share): define rollout and operations gates --- README.md | 10 ++ docs/connect-share-adoption-evidence.md | 47 +++++++- docs/connect-share-handoff.md | 65 ++++++++++ docs/connect-share-known-issues.md | 19 +++ docs/connect-share-launch.md | 80 +++++++++++++ docs/connect-share-marketplace-kit.md | 80 +++++++++++++ docs/connect-share-operations.md | 113 ++++++++++++++++++ docs/connect-share-threat-model.md | 53 ++++++++ docs/connect-share.md | 9 ++ ...-08-03-connect-share-release-operations.md | 54 +++++++++ 10 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 docs/connect-share-handoff.md create mode 100644 docs/connect-share-known-issues.md create mode 100644 docs/connect-share-launch.md create mode 100644 docs/connect-share-marketplace-kit.md create mode 100644 docs/connect-share-operations.md create mode 100644 docs/connect-share-threat-model.md create mode 100644 docs/plans/2026-08-03-connect-share-release-operations.md diff --git a/README.md b/README.md index 2758f6fad..d8fb9175a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,16 @@ See [the player, privacy, installation, and distribution guide](docs/connect-sha for the supported versions, required dependencies, player flow, and release details. +Release and adoption work is governed by the +[fallback operations](docs/connect-share-operations.md), +[threat model](docs/connect-share-threat-model.md), +[staged launch](docs/connect-share-launch.md), and +[HTTPS handoff](docs/connect-share-handoff.md) contracts. Those documents +separate repository evidence from external deployment and review gates. +Marketplace and support teams use the +[creator source kit](docs/connect-share-marketplace-kit.md) and +[known-issues page](docs/connect-share-known-issues.md). + The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See [docs/connect-share-testing.md](docs/connect-share-testing.md) for the manual diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index f01e0ec49..f01626c30 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -108,12 +108,24 @@ Status meanings: |---|---|---|---| | 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 | Deterministic proof | Fabric 1.20.1/1.21.1/1.21.11/26.2, Forge 1.20.1, and NeoForge 1.21.1 all built and passed packaged artifact tests | Real-client startup and join evidence remains required for every release target | -| Publish verified artifacts on Modrinth, CurseForge, and GitHub Releases | Gap | Artifacts have unambiguous loader/version archive names and deterministic local verification | Marketplace projects, credentials, signing/release workflow, public metadata, and final publication are external release operations and have not occurred from this unmerged PR | +| 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 package KotlinForForge in their distributable 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 | Deterministic proof | `TransportSelectorTest` covers LAN → internet → Connect ordering, mutual internet consent, one fallback attempt, and no-route failure | Force a direct failure and complete a real Connect fallback join after the external session-proposal boundary works | +| 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 | Gap | Real libp2p proxy E2E and clean-head offline Prism direct join pass; deterministic selector/auth/network refresh cases pass | Real Connect fallback, paid online-auth join, and network-change automation remain blocked by service/matrix environments | +| Real-client startup/join gate for every supported release target | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | + ## #99 — let friends join without installing the mod | Acceptance criterion | Status | Evidence | Remaining proof | @@ -151,6 +163,39 @@ Status meanings: | 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 | 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..c56274009 --- /dev/null +++ b/docs/connect-share-known-issues.md @@ -0,0 +1,19 @@ +# 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 | +|---|---|---| +| No-mod fallback | A vanilla client reaches the public Connect edge, but the tested edge did not deliver a session proposal to the local host, so admission and gameplay did not begin | Use two modded clients on the proven direct path; service owners must resolve and prove the edge/session boundary before advertising vanilla joining | +| 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 | Clean packaged direct-join product proof exists for Fabric 26.2 on macOS arm64; the remaining loader/version/OS/architecture matrix is deterministic only | Treat other artifacts as prerelease until their real-client startup and join 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..4bcd1c9be --- /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..a31a0019f --- /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-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 index 2d6fbc2d6..d5276a69e 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -132,6 +132,15 @@ 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 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..79a3c2fd0 --- /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. +- [ ] Run Markdown/link checks available in the repository, workflow syntax + checks, targeted tests, the broader build, and `git diff --check`. +- [ ] Push reviewed commits to PR #94; comment on each issue with completed + repository work and exact external gates. Keep the PR unmerged. From 9a0c947ef9012517bef7f721a0e3e270924df6d5 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 01:05:50 +0200 Subject: [PATCH 084/188] ci(share): attest release artifacts --- .github/workflows/connect-share-release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml index 9a6e7cc91..6bff82612 100644 --- a/.github/workflows/connect-share-release.yml +++ b/.github/workflows/connect-share-release.yml @@ -16,6 +16,9 @@ on: permissions: contents: write + id-token: write + attestations: write + artifact-metadata: write concurrency: group: connect-share-release-${{ inputs.release_tag }} @@ -85,6 +88,13 @@ jobs: 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 }} @@ -197,4 +207,5 @@ jobs: --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 From 11b02f554c48e5fa8b68b814edc08960aad1a060 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 01:15:58 +0200 Subject: [PATCH 085/188] docs(share): close repository operations checklist --- docs/plans/2026-08-03-connect-share-release-operations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-03-connect-share-release-operations.md b/docs/plans/2026-08-03-connect-share-release-operations.md index 79a3c2fd0..36bdaf6f5 100644 --- a/docs/plans/2026-08-03-connect-share-release-operations.md +++ b/docs/plans/2026-08-03-connect-share-release-operations.md @@ -48,7 +48,7 @@ external dependency to an owner, verification artifact, and issue criterion. ## Task 5: Evidence and handoff - [x] Extend the acceptance matrix for #117, #118, and #119. -- [ ] Run Markdown/link checks available in the repository, workflow syntax +- [x] Run Markdown/link checks available in the repository, workflow syntax checks, targeted tests, the broader build, and `git diff --check`. -- [ ] Push reviewed commits to PR #94; comment on each issue with completed +- [x] Push reviewed commits to PR #94; comment on each issue with completed repository work and exact external gates. Keep the PR unmerged. From 41e5989bee4a77e5653fecd793cae400fafe7ee1 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 10:11:13 +0200 Subject: [PATCH 086/188] fix(share): surface safe admission denials --- .../com/minekube/connect/register/WatcherRegister.java | 9 ++++++++- .../minekube/connect/register/WatcherRegisterTest.java | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 9c98ea732..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; @@ -438,9 +440,14 @@ private void complete( return; } if (!decision.isAllowed() && !decision.isDeferredToLocalLogin()) { + String safeMessage = decision.getSafeMessage(); reject(proposal, Status.newBuilder() .setCode(Code.PERMISSION_DENIED_VALUE) - .setMessage(decision.getSafeMessage()) + .setMessage(safeMessage) + .addDetails(Any.pack(LocalizedMessage.newBuilder() + .setLocale("en-US") + .setMessage(safeMessage) + .build())) .build()); return; } 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 bd3ff9acb..2b7f0806e 100644 --- a/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java +++ b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java @@ -22,6 +22,7 @@ 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; @@ -487,6 +488,11 @@ void deniedOrTimedOutAdmissionRejectsWithoutTunnelWork() throws Exception { 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); } From 78745ce1e2919edb8ca8a49177d91a5238685678 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 10:45:05 +0200 Subject: [PATCH 087/188] fix(share): harden join authorization grants --- share/AGENTS.md | 17 +++- .../share/admission/AdmissionController.kt | 26 ++++-- .../admission/AdmissionControllerTest.kt | 56 ++++++++++++- .../fabric/v1_20_1/FriendCardNetworking.kt | 1 + .../fabric/v1_21_1/FriendCardNetworking.kt | 1 + .../fabric/v1_21_11/FriendCardNetworking.kt | 1 + .../fabric/v26_2/FriendCardNetworking.kt | 1 + .../share/fabric/ApprovedJoinTracker.kt | 20 ++++- .../share/fabric/ApprovedJoinTrackerTest.kt | 83 ++++++++++++++++--- .../fabric/FabricSessionAdmissionGateTest.kt | 13 +-- .../v1_20_1/ForgeFriendCardNetworking.kt | 1 + .../v1_21_1/NeoForgeFriendCardNetworking.kt | 1 + 12 files changed, 190 insertions(+), 31 deletions(-) diff --git a/share/AGENTS.md b/share/AGENTS.md index 429ad2e9e..6dac06c9b 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -122,7 +122,22 @@ redesigned for Kotlin. - Connect's no-mod session admission must finish before vanilla's own connection timeout. Preserve a deadline buffer, cancel the pending host request when it expires, and test the guest-visible actionable denial; - generic `Timed out` is a failed UX result. + generic `Timed out` is a failed UX result. Encode an intentional denial as + `PermissionDenied` with the safe copy repeated in a + `google.rpc.LocalizedMessage` detail: Moxy intentionally never shows a + connector-controlled raw status message. A bounded `PermissionDenied` + response proves the proposal reached this connector, so diagnose host + admission rather than session delivery. +- An approved gameplay join may enable automatic friend-card exchange only + when its proof carries a direct peer ID and the subsequently supplied, + signature-verified invitation names that same peer. Name and Minecraft UUID + are not sufficient for offline or Connect-only sessions; fail closed rather + than turning an unbound admission into `AUTO_ACCEPT` friendship. +- `approveNextJoin` grants are one-shot admission capabilities, not durable + friend state. Expire them within the admission timeout, deduplicate them, + and bound the queue by `maxPending`; when full, evict the oldest grant so a + requester cannot accumulate arbitrary UUID grants or grow memory without + bound. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, then follow [the testing guide](../docs/connect-share-testing.md) for the complete two-client launch and evidence gates. Keep machine-specific paths 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 index e8db30569..7cc6a8f5a 100644 --- 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 @@ -20,11 +20,12 @@ class AdmissionController( 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 = mutableSetOf() + private val preapprovedJoins = linkedMapOf() private val mutablePending = MutableStateFlow>(emptyList()) val pending: StateFlow> = mutablePending.asStateFlow() @@ -51,7 +52,8 @@ class AdmissionController( return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } if (purpose == AdmissionPurpose.JOIN) { - val preapproved = preapprovedJoins.firstOrNull { + removeExpiredPreapprovals(nowMillis()) + val preapproved = preapprovedJoins.keys.firstOrNull { it.matches(identity) } if (preapproved != null) { @@ -137,7 +139,7 @@ class AdmissionController( purpose: AdmissionPurpose, ): Int { val denied = synchronized(lock) { - preapprovedJoins.removeIf { it.directPeerId == peerId } + preapprovedJoins.keys.removeIf { it.directPeerId == peerId } val matches = requests.entries.filter { entry -> entry.value.pending.purpose == purpose && entry.value.pending.identity.directPeerId == peerId @@ -155,7 +157,7 @@ class AdmissionController( minecraftUuid: UUID? = null, ): Int { val revoked = synchronized(lock) { - preapprovedJoins.removeIf { it.directPeerId == peerId } + preapprovedJoins.keys.removeIf { it.directPeerId == peerId } authenticatedApprovals.removeIf { it.directPeerId == peerId || ( @@ -197,10 +199,24 @@ class AdmissionController( fun approveNextJoin(identity: AdmissionIdentity) { synchronized(lock) { - preapprovedJoins += PreapprovedJoin( + 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 } } 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 index 9a5061413..25d018cb8 100644 --- 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 @@ -326,6 +326,57 @@ class AdmissionControllerTest { ) } + @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() @@ -381,13 +432,16 @@ class AdmissionControllerTest { connectedCount: () -> Int = { 0 }, maxGuests: () -> Int = { 8 }, autoApprove: (AdmissionIdentity) -> Boolean = { false }, + maxPending: Int = 16, + nowMillis: () -> Long = System::currentTimeMillis, ) = AdmissionController( scope = backgroundScope, timeout = 30.seconds, - maxPending = 16, + maxPending = maxPending, connectedCount = connectedCount, maxGuests = maxGuests, autoApprove = autoApprove, + nowMillis = nowMillis, ) private fun authenticated( 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 index 46ac46f6f..1e534b4f8 100644 --- 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 @@ -29,6 +29,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name, player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index 2387e836e..e3a3ebff2 100644 --- 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 @@ -35,6 +35,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name, player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index ddc97a07d..566857c3a 100644 --- 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 @@ -35,6 +35,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name(), player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index db7a3de3c..707ca5080 100644 --- 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 @@ -35,6 +35,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name(), player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index fd490811f..b8d865533 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -52,20 +54,30 @@ class ApprovedJoinTracker( approved.remove(key, timedProof) return false } - return true + return timedProof.directPeerId != null } fun consume( name: String, uuid: UUID, + invitation: String, ): ApprovedJoinProof? { val timedProof = approved.remove( PlayerKey(name.normalized(), uuid), ) ?: return null - return timedProof.proof.takeIf { - nowMillis() - timedProof.approvedAtMillis <= - PROOF_LIFETIME_MILLIS + 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( 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 index 3cf1ee680..0e8bc6a3d 100644 --- 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 @@ -4,34 +4,49 @@ 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`() { - tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + 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) + tracker.consume("Robin", PLAYER_UUID, invitation) ?.authenticatedMinecraftUuid, ) - assertNull(tracker.consume("Robin", PLAYER_UUID)) + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) } @Test - fun `approved offline identity proves pairing without trusting its uuid`() { - tracker.record(OFFLINE, AdmissionAnswer.ALLOW) + 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) + val proof = tracker.consume("Robin", PLAYER_UUID, invitation) assertNotNull(proof) assertNull(proof.authenticatedMinecraftUuid) @@ -42,15 +57,19 @@ class ApprovedJoinTrackerTest { tracker.record(AUTHENTICATED, AdmissionAnswer.DENY) assertEquals(false, tracker.hasProof("Robin", PLAYER_UUID)) - assertNull(tracker.consume("Robin", PLAYER_UUID)) + assertNull(tracker.consume("Robin", PLAYER_UUID, "invalid")) } @Test - fun `authentication proof expires before an unrelated later join`() { - tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + 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)) + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) } @Test @@ -70,6 +89,48 @@ class ApprovedJoinTrackerTest { 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") 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 index be1defab6..fbd95eb0c 100644 --- 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 @@ -102,10 +102,9 @@ class FabricSessionAdmissionGateTest { admission.answer(pending.requestId, allow = true) runCurrent() assertTrue(result.getNow(null).isAllowed) - assertEquals( - PLAYER_UUID, - approvedJoins.consume("Alex", PLAYER_UUID) - ?.authenticatedMinecraftUuid, + assertFalse( + approvedJoins.hasProof("Alex", PLAYER_UUID), + "a Connect-only identity cannot authorize automatic friendship", ) } @@ -235,11 +234,7 @@ class FabricSessionAdmissionGateTest { ) admission.answer(admission.pending.value.single().requestId, allow = true) assertEquals(AdmissionAnswer.ALLOW, authenticated.await()) - assertEquals( - PLAYER_UUID, - approvedJoins.consume("Alex", PLAYER_UUID) - ?.authenticatedMinecraftUuid, - ) + assertTrue(approvedJoins.hasProof("Alex", PLAYER_UUID)) val offline = async { local.request( 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 index 40c78ded2..62c5d6014 100644 --- 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 @@ -66,6 +66,7 @@ object ForgeFriendCardNetworking { val proof = handlers.approvedJoins.consume( player.gameProfile.name, player.uuid, + message.invitation, ) ?: return@consumerMainThread handlers.scope.launch(Dispatchers.IO) { handlers.receiver.receive( 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 index 0ede63d1e..ced6c04b9 100644 --- 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 @@ -60,6 +60,7 @@ object NeoForgeFriendCardNetworking { val proof = handlers.approvedJoins.consume( player.gameProfile.name, player.uuid, + payload.invitation, ) ?: return@playToServer handlers.scope.launch(Dispatchers.IO) { handlers.receiver.receive( From d993635dc204c0faf1907948c3199228363ee464 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 10:54:31 +0200 Subject: [PATCH 088/188] test(share): drive a real Connect fallback join --- share/AGENTS.md | 9 +++- .../share/fabric/PrismFriendJoinE2ETest.kt | 48 +++++++++++++------ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/share/AGENTS.md b/share/AGENTS.md index 6dac06c9b..011af97ac 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -141,8 +141,13 @@ redesigned for Kotlin. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, then follow [the testing guide](../docs/connect-share-testing.md) for the complete two-client launch and evidence gates. Keep machine-specific paths - in `LIVE_DATA`, `LIVE_PORT_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` - environment variables. + in `LIVE_DATA`, `LIVE_TARGET_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` + environment variables (`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. - Invoke the live harness with `--rerun-tasks`. Its environment variables are intentionally not task inputs, so an up-to-date result is not live evidence. - Keep only one host and one guest identity active during a live run. Cloning a 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 index 9520fa912..b22f6c306 100644 --- 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 @@ -13,7 +13,6 @@ import java.nio.file.attribute.BasicFileAttributes import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.test.fail import kotlinx.coroutines.delay @@ -83,17 +82,20 @@ class PrismFriendJoinE2ETest { fun `saved friend requests and joins a live singleplayer world`() = runBlocking { val dataValue = System.getenv("LIVE_DATA") - val portValue = System.getenv("LIVE_PORT_FILE") + val targetValue = System.getenv("LIVE_TARGET_FILE") + ?: System.getenv("LIVE_PORT_FILE") val hostLogValue = System.getenv("LIVE_HOST_LOG") assumeTrue( - dataValue != null && portValue != null && hostLogValue != null, - "LIVE_DATA, LIVE_PORT_FILE, and LIVE_HOST_LOG enable this E2E", + 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 portFile = Path.of(checkNotNull(portValue)) + 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() @@ -179,17 +181,26 @@ class PrismFriendJoinE2ETest { ).getOrNull() }, ) - val gameplay = assertIs( - browser.join( - friend, - DirectP2pAuthMode.OFFLINE, - ).getOrNull(), - ) + if (forceConnectFallback) { + forceDirectFailure(browser) + } + val gameplay = browser.join( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() ?: fail("No gameplay route was available") gameplay.use { - Files.writeString( - portFile, - gameplay.localAddress.port.toString(), - ) + 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() @@ -216,6 +227,13 @@ class PrismFriendJoinE2ETest { } } + 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, From bd72ea0090a1f4e047ce208d2b73d8fe52b76efd Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 11:06:29 +0200 Subject: [PATCH 089/188] docs(share): record fallback and admission evidence --- docs/connect-share-adoption-evidence.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index f01626c30..4de9f2762 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -118,32 +118,32 @@ Status meanings: | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| -| Direct first and exactly one automatic Connect fallback | Deterministic proof | `TransportSelectorTest` covers LAN → internet → Connect ordering, mutual internet consent, one fallback attempt, and no-route failure | Force a direct failure and complete a real Connect fallback join after the external session-proposal boundary works | +| 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 | Gap | Real libp2p proxy E2E and clean-head offline Prism direct join pass; deterministic selector/auth/network refresh cases pass | Real Connect fallback, paid online-auth join, and network-change automation remain blocked by service/matrix environments | +| Automated two-client direct, fallback, offline, online, and network-change cases | Partial product proof | Real libp2p direct and forced Connect fallback Prism 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 | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | ## #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 reached Connecting through the ordinary public address | Inspect the copy action, then resolve the external Connect forwarding boundary and complete a vanilla join | +| 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 reached the active connector through the ordinary public address and received the bounded host-admission rejection | Inspect the copy action and record one human-approved vanilla join; Minecraft UI approval is intentionally not automated | | 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | | 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 required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; the first product probe reproduced generic `Timed out` and drove the fix | The Connect edge must deliver a session before the rebuilt denial can be observed on vanilla | +| Errors distinguish unavailable host from invalid or expired admission | Product proof required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; a live no-mod probe returned connector `PermissionDenied`, proving delivery, and the connector now sends safe copy in `google.rpc.LocalizedMessage` | Moxy PR #512 must be merged and deployed through its guarded rollout before the rebuilt terminal denial can be observed on vanilla | ## #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` (`offline reconnect with copied name requires a new approval`) | 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 | @@ -210,9 +210,11 @@ Status meanings: ## Open foundation gaps The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the -remaining exact-head product claims are observed. The deterministic gaps found -in the first audit are fixed in `9397658c`; the direct Prism join is proven and -the no-mod attempt is now blocked specifically at external Connect session -forwarding. Minecraft UI clicks are never automated; any irreducible approval -interaction is recorded as a human checkpoint with all other evidence gathered -noninteractively. +remaining exact-head product claims are observed. The first audit fixes remain, +the direct and forced Connect-fallback 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 probe now proves Connect +session delivery and host admission; only the explicit human acceptance pass +and the unmerged Moxy terminal-denial rollout remain. Minecraft UI clicks are +never automated, so that irreducible approval interaction is recorded as a +human checkpoint while all other evidence is gathered noninteractively. From c7c7748c79ee857d233c9ec20a22e3f256c2fb83 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 16:49:24 +0200 Subject: [PATCH 090/188] docs(share): record vanilla join proof --- .../skills/connect-share-prism-e2e/SKILL.md | 16 +++++++++ docs/connect-share-adoption-evidence.md | 36 +++++++++++++------ share/AGENTS.md | 13 +++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index be34cb0b6..4934a5c9d 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -64,6 +64,12 @@ 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 @@ -136,6 +142,16 @@ 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. diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 4de9f2762..a3e056bda 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -24,7 +24,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. - Current source head for product probes: - `81ac77b0244db0e6b29abc97559f641f2e935710`. + `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`. @@ -52,6 +52,19 @@ Status meanings: `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. - 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 @@ -123,18 +136,18 @@ Status meanings: | 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 and forced Connect fallback Prism 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 | +| 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 | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | ## #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 reached the active connector through the ordinary public address and received the bounded host-admission rejection | Inspect the copy action and record one human-approved vanilla join; Minecraft UI approval is intentionally not automated | +| 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | +| 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 required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; a live no-mod probe returned connector `PermissionDenied`, proving delivery, and the connector now sends safe copy in `google.rpc.LocalizedMessage` | Moxy PR #512 must be merged and deployed through its guarded rollout before the rebuilt terminal denial can be observed on vanilla | @@ -211,10 +224,11 @@ Status meanings: 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 and forced Connect-fallback 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 probe now proves Connect -session delivery and host admission; only the explicit human acceptance pass -and the unmerged Moxy terminal-denial rollout remain. Minecraft UI clicks are -never automated, so that irreducible approval interaction is recorded as a -human checkpoint while all other evidence is gathered noninteractively. +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 remains intentionally unmerged and its +terminal-denial behavior therefore remains undeployed; production must not be +called fixed for that rejection UX until the guarded Moxy rollout and live +denial smoke test are complete. diff --git a/share/AGENTS.md b/share/AGENTS.md index 011af97ac..e4dd7eeb2 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -73,6 +73,11 @@ redesigned for Kotlin. `prismlauncher --launch --offline --server `. `--offline ` is authoritative; editing `InstanceAccountId` while Prism runs is not, because Prism rewrites it. +- A matching Prism JVM PID does not prove a fresh launch. Snapshot + `minecraft/logs/latest.log` before launch, require a newer mtime plus the + expected world/runtime markers, and treat an old JVM with an unchanged log as + an occupied stale instance. Before terminating one, resolve exactly one PID + by its instance working directory; never kill a broad Java process set. - Prove the flow in layers: mDNS discovery, authenticated friend activity, Minecraft status when host privacy permits it, then follow [the testing guide](../docs/connect-share-testing.md) for the real two-client login @@ -119,6 +124,14 @@ redesigned for Kotlin. the confirmed test friend, send the real libp2p join request, and restore the permission afterwards. Keep machine-specific instance paths and credentials in environment variables, never in committed tests or scripts. +- A vanilla no-mod Connect join has no signed direct-peer proof. Its Connect + profile may therefore require an ordinary pending admission even when a + same-named offline friend is set to auto-accept; do not weaken UUID/peer + matching to make a test pass. An unattended local proof may attach a + temporary, uncommitted driver that resolves the existing `ShareViewModel`, + asserts exactly one pending admission, and invokes its normal `allow` action. + Emit only stage/result booleans, remove the driver afterwards, and never ship + a production bypass or log the pending identity/request ID. - Connect's no-mod session admission must finish before vanilla's own connection timeout. Preserve a deadline buffer, cancel the pending host request when it expires, and test the guest-visible actionable denial; From b4dfbb770b2753d5571d3c27c18301f36da2ac5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= Date: Mon, 3 Aug 2026 17:36:18 +0200 Subject: [PATCH 091/188] feat(bedrock): consume signed principal v2 (#129) * feat: consume Bedrock principal v2 in Connect Java * no-mistakes(review): Harden Bedrock v2 admission and negotiated readiness * no-mistakes(review): Bind shared Bedrock readiness across startup and libp2p * no-mistakes(document): Document Bedrock v2 defaults and remove stale duplicates --------- Co-authored-by: no-mistakes[bot] --- AGENTS.md | 10 + README.md | 6 +- .../com/minekube/connect/api/ConnectApi.java | 9 + .../bedrock/BedrockIdentityProfiles.java | 5 +- .../principal/BedrockPrincipalVerifier.java | 8 + .../BedrockPrincipalVerifierFactory.java | 13 + .../api/player/principal/CanonicalXuid.java | 31 + .../DefaultBedrockPrincipalVerifier.java | 560 ++++++++++++++++++ .../principal/EffectiveGameProfile.java | 12 + .../ImmutableVerifiedBedrockPrincipal.java | 53 ++ .../api/player/principal/LinkProvenance.java | 29 + .../player/principal/PrincipalBindings.java | 83 +++ .../api/player/principal/PrincipalError.java | 18 + .../PrincipalVerificationException.java | 17 + .../principal/SignedPrincipalEnvelope.java | 32 + .../api/player/principal/StrictJson.java | 184 ++++++ .../api/player/principal/SubjectKind.java | 24 + .../principal/TrustedProposalContext.java | 19 + .../principal/VerificationEvidence.java | 20 + .../principal/VerifiedBedrockPrincipal.java | 15 + .../principal/VerifiedLinkedJavaIdentity.java | 26 + .../player/principal/VerifiedPrincipal.java | 7 + .../principal/VerifierConfiguration.java | 68 +++ .../connect.base-conventions.gradle.kts | 4 +- .../listener/BungeeLateReassertListener.java | 5 +- .../connect/api/SimpleConnectApi.java | 11 + .../bedrock/BedrockAdmissionCoordinator.java | 45 +- .../BedrockPrincipalAdmissionException.java | 18 + .../BedrockPrincipalConfiguration.java | 61 ++ .../bedrock/BedrockPrincipalConsumer.java | 153 +++++ .../bedrock/BedrockPrincipalReadiness.java | 156 +++++ .../VerifiedBedrockIdentityRegistry.java | 39 +- .../connect/config/ConnectConfig.java | 27 + .../minekube/connect/module/CommonModule.java | 7 + .../netty/LocalChannelInboundHandler.java | 7 +- .../connect/network/netty/LocalSession.java | 10 +- .../connect/tunnel/p2p/Libp2pEndpoint.java | 4 + .../tunnel/p2p/Libp2pEndpointRuntime.java | 30 +- .../tunnel/p2p/Libp2pSessionMapper.java | 6 + .../connect/tunnel/p2p/P2PFrameCodec.java | 58 ++ .../connect/tunnel/p2p/P2PFrameDecoder.java | 3 + .../tunnel/p2p/PeerRegistrationClient.java | 147 ++++- .../tunnel/p2p/PeerRegistrationHandshake.java | 84 ++- .../connect/watch/SessionProposal.java | 47 +- .../minekube/connect/watch/WatchClient.java | 35 +- .../connect/v1alpha1/connect_libp2p.proto | 17 + .../connect/v1alpha1/watch_service.proto | 81 ++- core/src/main/resources/config.yml | 16 +- core/src/main/resources/proxy-config.yml | 16 +- .../bedrock/BedrockPrincipalConsumerTest.java | 159 +++++ .../BedrockPrincipalGenerationConfigTest.java | 118 ++++ .../BedrockPrincipalReadinessTest.java | 114 ++++ .../BedrockPrincipalCoreVectorTest.java | 273 +++++++++ .../BedrockPrincipalWireBoundaryTest.java | 63 ++ .../PrincipalConstructionBoundaryTest.java | 57 ++ .../principal/PrincipalPrivacyTest.java | 103 ++++ .../startup/PluginGraphStartupTest.java | 11 +- .../p2p/Libp2pEndpointRuntimeInitTest.java | 10 + .../connect/tunnel/p2p/P2PFrameCodecTest.java | 23 + .../p2p/PeerRegistrationClientTest.java | 95 +++ .../p2p/PeerRegistrationHandshakeTest.java | 28 + .../connect/watch/WatchClientTest.java | 44 ++ .../resources/bedrock-principal-v2/UPSTREAM | 4 + .../bedrock-principal-v2/core-vectors.json | 144 +++++ .../bedrock-principal-v2/v2.schema.json | 53 ++ docs/bedrock-identity.md | 60 +- ...2026-07-06-bedrock-identity-enforcement.md | 7 +- ...-06-bedrock-identity-enforcement-design.md | 7 +- .../connect/addon/data/SpigotDataHandler.java | 10 +- .../connect/listener/SpigotListener.java | 12 +- velocity/build.gradle.kts | 4 +- .../VelocityLateReassertListener.java | 2 +- 72 files changed, 3650 insertions(+), 87 deletions(-) create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifier.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/BedrockPrincipalVerifierFactory.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/CanonicalXuid.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/DefaultBedrockPrincipalVerifier.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/EffectiveGameProfile.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/ImmutableVerifiedBedrockPrincipal.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/LinkProvenance.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/PrincipalBindings.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/PrincipalError.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/PrincipalVerificationException.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/SignedPrincipalEnvelope.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/StrictJson.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/SubjectKind.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/TrustedProposalContext.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerificationEvidence.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifiedBedrockPrincipal.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifiedLinkedJavaIdentity.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifiedPrincipal.java create mode 100644 api/src/main/java/com/minekube/connect/api/player/principal/VerifierConfiguration.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalAdmissionException.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConfiguration.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalConsumer.java create mode 100644 core/src/main/java/com/minekube/connect/bedrock/BedrockPrincipalReadiness.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalConsumerTest.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalGenerationConfigTest.java create mode 100644 core/src/test/java/com/minekube/connect/bedrock/BedrockPrincipalReadinessTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/BedrockPrincipalCoreVectorTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/BedrockPrincipalWireBoundaryTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/PrincipalConstructionBoundaryTest.java create mode 100644 core/src/test/java/com/minekube/connect/principal/PrincipalPrivacyTest.java create mode 100644 core/src/test/resources/bedrock-principal-v2/UPSTREAM create mode 100644 core/src/test/resources/bedrock-principal-v2/core-vectors.json create mode 100644 core/src/test/resources/bedrock-principal-v2/v2.schema.json 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/src/main/kotlin/connect.base-conventions.gradle.kts b/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts index a64bf8267..0189a5e47 100644 --- a/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.base-conventions.gradle.kts @@ -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/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/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..b7631b406 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,7 @@ import com.minekube.connect.util.Utils; import java.util.Collections; import java.util.List; +import java.util.Map; import lombok.Getter; /** @@ -60,6 +61,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. @@ -127,4 +134,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/module/CommonModule.java b/core/src/main/java/com/minekube/connect/module/CommonModule.java index 95d9f689c..14ff93bbb 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -41,6 +41,7 @@ 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; @@ -137,6 +138,12 @@ public BedrockIdentityReadiness bedrockIdentityReadiness( return new BedrockIdentityReadiness(configHolder.get(), keyProvider); } + @Provides + @Singleton + public BedrockPrincipalReadiness bedrockPrincipalReadiness(ConfigHolder configHolder) { + return new BedrockPrincipalReadiness(configHolder.get()); + } + @Provides @Singleton @Named("connectToken") 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/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/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/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/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/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/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/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/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/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/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/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/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/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() { From 5e506047ad3200034f065a4e93cfabcfb38f257d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:36:40 +0000 Subject: [PATCH 092/188] chore(main): release 0.15.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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/CHANGELOG.md b/CHANGELOG.md index b617829b8..b213d6a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.15.0](https://github.com/minekube/connect-java/compare/0.14.0...0.15.0) (2026-08-03) + + +### Features + +* **bedrock:** consume signed principal v2 ([#129](https://github.com/minekube/connect-java/issues/129)) ([b4dfbb7](https://github.com/minekube/connect-java/commit/b4dfbb770b2753d5571d3c27c18301f36da2ac5c)) + ## [0.14.0](https://github.com/minekube/connect-java/compare/0.13.3...0.14.0) (2026-07-30) From 32755bc1d937dac08e590140dc0abd1edd7726ec Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 17:37:53 +0200 Subject: [PATCH 093/188] docs: design Connect Share Fabric mod --- .../2026-07-30-connect-share-mod-design.md | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-connect-share-mod-design.md 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..e29ab01fc --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -0,0 +1,396 @@ +# Connect Share Mod Design + +**Date:** 2026-07-30 +**Status:** Architecture approved; written-spec review pending +**Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) + +## 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 a temporary Minekube Connect address. 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 Connect** pause-menu + action; they do not press Minecraft's Open to LAN button. +- No listener is exposed on a LAN or WAN interface. +- Every share creates a new temporary Connect address. Stopping the share or + leaving the world makes that address unreachable, and a later share receives + a different address. +- Connect authenticates vanilla guests at the edge. The mod preserves the + resulting verified player context when it injects the session locally. +- The host must approve every new verified Minecraft UUID. An approval is + remembered only for the current share session. +- 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. +- Offline-mode Java accounts are not supported in the first release. +- 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 a temporary Connect hostname. +3. Reuse Connect's authenticated session 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. + +## 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 +- Offline/cracked-account support +- 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. + +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` +- 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 verified session 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 a verified +login until `AdmissionController` accepts or rejects it. + +### ConnectShareIngress + +Creates a fresh random endpoint name and endpoint token for each share. It +starts the existing Connect watch/libp2p connector runtime against the local +server address and stops it with the share. + +The first implementation treats the endpoint as ephemeral by lifetime: + +- credentials live only in the active share object; +- credentials are never written to the normal persistent plugin config; +- a later share never reuses them; +- stopping the watch/registration makes the address unreachable. + +The endpoint record may remain reserved in the Connect control plane after it +goes offline. Control-plane deletion or a first-class expiring lease is an +additive service improvement and is not required for the address to be +unreachable or non-reusable by this mod. + +Connect session proposals remain pending while the host approves the verified +profile. Denial, timeout, world shutdown, and capacity exhaustion reject the +proposal before a local tunnel is opened. + +### 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. + +Every share creates an ephemeral libp2p identity so separate shares cannot be +correlated by a stable peer ID. 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 ordinary Minecraft login bytes into the same local +Minecraft initializer. Minecraft performs normal online-mode authentication +for this path. The host admission hook runs after the profile is authenticated +and before the player enters the world. + +### ShareInviteCodec + +A copied invitation is a versioned URI: + +```text +minekube://share/{base64url-cbor-payload} +``` + +The signed payload contains: + +- wire protocol version; +- share ID; +- expiry; +- temporary Connect hostname when Connect is available; +- ephemeral host peer ID; +- direct candidates only when the host enabled internet P2P; +- an unguessable per-share capability; +- the host peer signature over every preceding field. + +The capability authorizes requesting admission; it never bypasses host +approval or Minecraft account authentication. 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 Share screen. Pasting the URI into Minecraft's +Direct Connection field is detected by the mod and routed through the same +parser. + +### AdmissionController + +Admission is keyed by authenticated Minecraft UUID, not username, IP address, +or libp2p peer ID. + +For a new UUID, the controller: + +1. creates one pending request; +2. shows the host the verified name, UUID, and ingress type; +3. offers **Allow** and **Deny** actions; +4. expires the request after 30 seconds; +5. remembers an allowed UUID until this share stops. + +Duplicate requests for the same UUID share one decision. At most 16 requests +may be pending. 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 Connect**. The setup screen shows: + +- game mode; +- allow-cheats option; +- maximum guests, default 8 and range 1–16; +- **Allow direct internet connections**, off by default, with an IP-disclosure + warning; +- **Start Sharing**. + +While active, the screen shows: + +- temporary 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**. + +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 temporary hostname. Modded +guests can use **Join Share** or paste a `minekube://share/` invitation. + +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. + +## Security and Privacy + +- Connect identity is accepted only from a session context produced by the + managed Connect ingress. +- Direct sessions complete normal Mojang/Microsoft online-mode authentication + in the integrated server before admission. +- Every ingress requires host approval for a previously unseen UUID. +- Approvals, endpoint credentials, share capabilities, and ephemeral peer + identities die with the share. +- 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. +- 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 temporary endpoint, the UI shows the + sanitized watch-service reason and offers retry with fresh credentials. +- 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; +- temporary credential non-reuse; +- admission allow, deny, duplicate, timeout, capacity, 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; +- direct stream reaches the vanilla child initializer without a public bind; +- 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 a new UUID and verify approval resets after restart. +4. Join automatically between two modded clients on one LAN with Connect + unavailable. +5. Verify internet direct is never attempted without confirmation on both + peers. +6. Verify successful internet direct where NAT permits it. +7. Verify a failed internet-direct attempt falls back to Connect. +8. Stop sharing and prove the old hostname and invitation no longer reach the + world. +9. 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 temporary 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. From 9248eed8d2abe858871bf6da4867a4cff8032959 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 17:41:58 +0200 Subject: [PATCH 094/188] docs: persist Connect Share endpoint identity --- .../2026-07-30-connect-share-mod-design.md | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) 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 index e29ab01fc..e3372b0c5 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -8,9 +8,9 @@ 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 a temporary Minekube Connect address. Guests -with the mod can additionally use a direct libp2p connection when both sides -permit it. +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 @@ -26,9 +26,12 @@ logic is written in Kotlin and shared across both versions. - The host starts sharing from a dedicated **Share with Connect** pause-menu action; they do not press Minecraft's Open to LAN button. - No listener is exposed on a LAN or WAN interface. -- Every share creates a new temporary Connect address. Stopping the share or - leaving the world makes that address unreachable, and a later share receives - a different address. +- 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. +- Stopping the share or leaving the world makes the stable endpoint + unreachable until the host explicitly starts another share. - Connect authenticates vanilla guests at the edge. The mod preserves the resulting verified player context when it injects the session locally. - The host must approve every new verified Minecraft UUID. An approval is @@ -48,7 +51,8 @@ logic is written in Kotlin and shared across both versions. 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 a temporary Connect hostname. +2. Let an unmodified Java client join through the host's Connect hostname + while sharing is active. 3. Reuse Connect's authenticated session and tunnel semantics instead of creating a parallel public ingress service. 4. Let two modded clients connect directly on the same LAN without Connect. @@ -154,21 +158,32 @@ login until `AdmissionController` accepts or rejects it. ### ConnectShareIngress -Creates a fresh random endpoint name and endpoint token for each share. It -starts the existing Connect watch/libp2p connector runtime against the local -server address and stops it with the share. +Loads or creates one persistent Connect identity for the Minecraft +installation: -The first implementation treats the endpoint as ephemeral by lifetime: - -- credentials live only in the active share object; -- credentials are never written to the normal persistent plugin config; -- a later share never reuses them; -- stopping the watch/registration makes the address unreachable. +```text +config/minekube-connect-share/config.json +config/minekube-connect-share/token.json +``` -The endpoint record may remain reserved in the Connect control plane after it -goes offline. Control-plane deletion or a first-class expiring lease is an -additive service improvement and is not required for the address to be -unreachable or non-reusable by this mod. +`config.json` stores the endpoint name and non-secret user settings. +`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. `CONNECT_SHARE_ENDPOINT` and `CONNECT_SHARE_TOKEN` override +the files for development and managed launchers without colliding with a +server plugin in the same process. + +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 Connect identity**. Resetting warns that it creates +a new endpoint and invalidates the old local identity. Connect session proposals remain pending while the host approves the verified profile. Denial, timeout, world shutdown, and capacity exhaustion reject the @@ -206,7 +221,7 @@ The signed payload contains: - wire protocol version; - share ID; - expiry; -- temporary Connect hostname when Connect is available; +- persistent Connect hostname when Connect is available; - ephemeral host peer ID; - direct candidates only when the host enabled internet P2P; - an unguessable per-share capability; @@ -270,7 +285,7 @@ The pause menu contains **Share with Connect**. The setup screen shows: While active, the screen shows: -- temporary Connect address and copy button; +- Connect address and copy button; - copyable full mod invitation; - Connect, LAN direct, and internet direct status separately; - connected and approved players; @@ -282,8 +297,10 @@ the screen does not stop sharing. ### Guest -Vanilla guests add or directly connect to the temporary hostname. Modded -guests can use **Join Share** or paste a `minekube://share/` invitation. +Vanilla guests add or directly connect to the host's Connect hostname. Modded +guests can use **Join Share** or paste a `minekube://share/` invitation. The +hostname is stable and is not treated as a secret; verified identity 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 @@ -296,8 +313,10 @@ will learn each other's IP address. - Direct sessions complete normal Mojang/Microsoft online-mode authentication in the integrated server before admission. - Every ingress requires host approval for a previously unseen UUID. -- Approvals, endpoint credentials, share capabilities, and ephemeral peer - identities die with the share. +- Approvals, share capabilities, and ephemeral peer identities die with the + share. The 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. @@ -315,8 +334,9 @@ will learn each other's IP address. - 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 temporary endpoint, the UI shows the - sanitized watch-service reason and offers retry with fresh credentials. +- 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. @@ -326,7 +346,8 @@ will learn each other's IP address. ### Common unit tests - state-machine transitions and idempotent cleanup; -- temporary credential non-reuse; +- persistent endpoint creation, reload, environment override, redaction, + cross-world reuse, and explicit-only reset; - admission allow, deny, duplicate, timeout, capacity, and share reset; - invitation round-trip, signature, expiry, version, capability, and redaction; - transport order, privacy opt-in, timeouts, and Connect fallback. @@ -374,9 +395,10 @@ Before calling the feature complete: peers. 6. Verify successful internet direct where NAT permits it. 7. Verify a failed internet-direct attempt falls back to Connect. -8. Stop sharing and prove the old hostname and invitation no longer reach the - world. -9. Confirm no LAN/WAN Minecraft listener is reachable from another machine. +8. Stop sharing and prove the hostname no longer reaches the world. +9. Start a different world and prove the same endpoint name and token are + reused while the old signed invitation is rejected. +10. Confirm no LAN/WAN Minecraft listener is reachable from another machine. ## Delivery Sequence @@ -385,7 +407,7 @@ final scope: 1. Kotlin/Fabric multi-version build, share state, admission, invitations, and version adapters. -2. Integrated-server local bridge and temporary Connect ingress for vanilla +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. From 0e60133a21a55803a0ee3ba0c9c6f585abf61b11 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 17:51:12 +0200 Subject: [PATCH 095/188] docs: support imported and offline Share identities --- .../2026-07-30-connect-share-mod-design.md | 193 +++++++++++++----- 1 file changed, 143 insertions(+), 50 deletions(-) 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 index e3372b0c5..0fd5cc0fa 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -1,7 +1,7 @@ # Connect Share Mod Design **Date:** 2026-07-30 -**Status:** Architecture approved; written-spec review pending +**Status:** Approved for implementation **Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) ## Summary @@ -30,12 +30,19 @@ logic is written in Kotlin and shared across both versions. 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 authenticates vanilla guests at the edge. The mod preserves the - resulting verified player context when it injects the session locally. -- The host must approve every new verified Minecraft UUID. An approval is - remembered only for the current share session. +- 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 @@ -43,7 +50,6 @@ logic is written in Kotlin and shared across both versions. - 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. -- Offline-mode Java accounts are not supported in the first release. - Fabric builds are published for Minecraft 1.21.11 and 26.2. NeoForge is a later adapter, not part of this implementation. @@ -53,13 +59,17 @@ logic is written in Kotlin and shared across both versions. 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 authenticated session and tunnel semantics instead of - creating a parallel public ingress service. +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, public + hostname, and attached custom domains 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 @@ -68,7 +78,6 @@ logic is written in Kotlin and shared across both versions. - A friend graph, social network, or persistent invitations - UPnP-based public Minecraft TCP listeners - An independent libp2p relay network -- Offline/cracked-account support - Bedrock guest support in the first mod release - Voice chat tunneling - NeoForge, Forge, or Quilt artifacts in the first release @@ -145,16 +154,19 @@ 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 verified session into -the accepted local channel. +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 a verified -login until `AdmissionController` accepts or rejects it. +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 @@ -170,9 +182,15 @@ config/minekube-connect-share/token.json `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. `CONNECT_SHARE_ENDPOINT` and `CONNECT_SHARE_TOKEN` override -the files for development and managed launchers without colliding with a -server plugin in the same process. +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 @@ -185,9 +203,33 @@ rotation. The UI explains the mismatch and lets the user restore the token or explicitly choose **Reset Connect identity**. Resetting warns that it creates a new endpoint and invalidates the old local identity. -Connect session proposals remain pending while the host approves the verified -profile. Denial, timeout, world shutdown, and capacity exhaustion reject the -proposal before a local tunnel is opened. +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. + +Connect session proposals remain pending while the host approves the supplied +profile and its displayed trust level. The connector advertises support for +offline-mode players, as the Connect plugin can. Denial, timeout, world +shutdown, and capacity exhaustion reject the proposal before a local tunnel +is opened. ### DirectP2pIngress @@ -203,10 +245,18 @@ correlated by a stable peer ID. The direct service supports: - coordinated QUIC hole punching when candidate exchange is available; - no circuit-relay candidates outside the managed Connect path. -The direct stream carries ordinary Minecraft login bytes into the same local -Minecraft initializer. Minecraft performs normal online-mode authentication -for this path. The host admission hook runs after the profile is authenticated -and before the player enters the world. +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 @@ -228,9 +278,9 @@ The signed payload contains: - the host peer signature over every preceding field. The capability authorizes requesting admission; it never bypasses host -approval or Minecraft account authentication. Same-LAN discovery advertises -the share ID, protocol version, peer ID, and a short display name, but not the -internet capability or public candidates. +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 Share screen. Pasting the URI into Minecraft's @@ -239,20 +289,28 @@ parser. ### AdmissionController -Admission is keyed by authenticated Minecraft UUID, not username, IP address, -or libp2p peer ID. +Admission uses an explicit identity type: -For a new UUID, the controller: +```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 verified name, UUID, and ingress type; +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 UUID until this share stops. +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 UUID share one decision. At most 16 requests -may be pending. Excess requests are rejected. Denial and timeout are visible -to the guest without exposing internal errors. +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 @@ -292,6 +350,12 @@ While active, the screen shows: - pending approval cards; - **Stop Sharing**. +Connect identity settings show the endpoint name, credential source +(generated, imported, or environment), and a masked token status. They provide +**Import existing endpoint** and the separately warned **Reset Connect +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. @@ -299,20 +363,28 @@ the screen does not stop sharing. Vanilla guests add or directly connect to the host's Connect hostname. Modded guests can use **Join Share** or paste a `minekube://share/` invitation. The -hostname is stable and is not treated as a secret; verified identity and host -approval remain the authorization boundary. +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. +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. -- Direct sessions complete normal Mojang/Microsoft online-mode authentication - in the integrated server before admission. -- Every ingress requires host approval for a previously unseen UUID. +- 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. - Approvals, share capabilities, and ephemeral peer identities die with the share. The Connect endpoint name and token persist across shares. - The persistent endpoint token is stored separately from ordinary settings, @@ -322,7 +394,8 @@ will learn each other's IP address. 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. + share. Admission attempts are additionally bounded per Connect session or + ephemeral direct peer. - Malformed, expired, unsupported-version, incorrectly signed, or capability-mismatched invitations are rejected before dialing. @@ -348,14 +421,24 @@ will learn each other's IP address. - state-machine transitions and idempotent cleanup; - persistent endpoint creation, reload, environment override, redaction, cross-world reuse, and explicit-only reset; -- admission allow, deny, duplicate, timeout, capacity, and share 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; @@ -388,17 +471,27 @@ 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 a new UUID and verify approval resets after restart. -4. Join automatically between two modded clients on one LAN with Connect +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. -5. Verify internet direct is never attempted without confirmation on both +7. Verify internet direct is never attempted without confirmation on both peers. -6. Verify successful internet direct where NAT permits it. -7. Verify a failed internet-direct attempt falls back to Connect. -8. Stop sharing and prove the hostname no longer reaches the world. -9. Start a different world and prove the same endpoint name and token are +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 and token are reused while the old signed invitation is rejected. -10. Confirm no LAN/WAN Minecraft listener is reachable from another machine. +12. Import a dashboard-created endpoint and token, then prove its hostname and + attached dashboard configuration are 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 From 734b0fd603594e152eed9ffc34346547773d3d5e Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:06:04 +0200 Subject: [PATCH 096/188] docs: plan Connect Share singleplayer ingress --- .../2026-07-30-connect-share-singleplayer.md | 1303 +++++++++++++++++ .../2026-07-30-connect-share-mod-design.md | 14 +- 2 files changed, 1312 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md 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..bd9fe825e --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -0,0 +1,1303 @@ +# 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/libp2p versions. +- `.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: `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`, and `loomVersion`. + +- [ ] **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 +} +``` + +- [ ] **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 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 +``` + +- [ ] **Step 3: Keep plugin and Fabric conventions separate** + +In root `build.gradle.kts`, define: + +```kotlin +val fabricProjects = setOf( + projects.share.common, + projects.share.fabricCommon, + projects.share.fabric12111, + projects.share.fabric262, +).map { it.dependencyProject } +``` + +Apply the existing Java-11/Lombok/Shadow conventions only when `this !in fabricProjects`. 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. + +The `share/common` dependencies are: + +```kotlin +implementation(projects.core) +implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") +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("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 `Versions.fabricApi262Version`. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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)`. + +- [ ] **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)); +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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): CredentialValidation +} +sealed interface CredentialValidation { + data object Valid : CredentialValidation + data class Invalid(val safeMessage: String) : CredentialValidation +} +``` + +- [ ] **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`() +``` + +Use a deterministic `EndpointNameSource { "amber-fox" }` and token source returning `T-AAAAAAAAAAAAAAAAAAAA`. + +- [ ] **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`. + +- [ ] **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, + ): CredentialValidation + suspend fun importTokenFile( + endpoint: String, + tokenFile: Path, + validator: EndpointCredentialValidator, + ): CredentialValidation + suspend fun resetConfirmed(): EndpointIdentity +} +``` + +Use `config.json` with: + +```json +{"endpoint":"amber-fox","credentialSource":"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 `CredentialValidation.Valid`, then atomically replaces token first and config second while retaining backups until both moves succeed. Restore both backups when the second move fails. + +Before either move, write `identity-transaction.json` containing the old and +new endpoint names plus both backup file names. `currentOrCreate()` calls +`recoverInterruptedTransaction()` before reading identity files. When the +journal exists, restore both backups, or remove both partially created files +when no prior identity existed, then delete the journal. Delete backups and the +journal only after both final files are durable. A process crash during either +move therefore rolls back on the next load. + +- [ ] **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 `CredentialValidation.Invalid`; transport failure returns a safe network message. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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 } +``` + +- [ ] **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. + +- [ ] **Step 2: Run and observe failure** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.admission.AdmissionControllerTest +``` + +Expected: missing admission types. + +- [ ] **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. + +- [ ] **Step 4: Run tests** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.admission.AdmissionControllerTest +``` + +Expected: all seven cases pass. + +- [ ] **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(); +} +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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, +) +``` + +- [ ] **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`() +``` + +- [ ] **Step 2: Run and observe missing production types** + +Run: + +```bash +./gradlew :share:common:test --tests com.minekube.connect.share.ShareCoordinatorTest +``` + +Expected: compilation failure. + +- [ ] **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`. `stop` snapshots handles under the mutex, publishes `Stopping`, closes ingress, closes bridge, resets admission, then publishes `Idle`. Every close runs even when a previous close throws; aggregate failures into logs but keep UI messages sanitized. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. 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`. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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) +} +``` + +- [ ] **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. + +- [ ] **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. + +- [ ] **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 Connect** when idle and **Connect Share** when active. + +The setup screen contains game mode, cheats, max guests default 8, and **Start Sharing**. + +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**; +- **Endpoint identity** link. + +The identity screen contains: + +- endpoint name; +- masked credential source; +- **Import existing endpoint**; +- endpoint field plus masked token field; +- `token.json` chooser; +- **Validate and save**; +- warned **Reset Connect 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 hostname/custom-domain configuration. +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-30-connect-share-mod-design.md b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md index 0fd5cc0fa..9b1339301 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -225,11 +225,15 @@ 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. -Connect session proposals remain pending while the host approves the supplied -profile and its displayed trust level. The connector advertises support for -offline-mode players, as the Connect plugin can. Denial, timeout, world -shutdown, and capacity exhaustion reject the proposal before a local tunnel -is opened. +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 From 0358b002943d73c57860ad36f1aa0bf8ac71e0bf Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:33:38 +0200 Subject: [PATCH 097/188] build: add multi-version Fabric Share modules --- build-logic/build.gradle.kts | 13 +-- build-logic/src/main/kotlin/Versions.kt | 10 ++- .../connect.base-conventions.gradle.kts | 2 +- .../connect.publish-conventions.gradle.kts | 3 +- .../connect.shadow-conventions.gradle.kts | 2 +- build-logic/src/main/kotlin/extensions.kt | 16 ++-- build.gradle.kts | 42 +++++---- core/build.gradle.kts | 15 ++-- .../minekube/connect/util/Constants.java.peb} | 6 +- .../2026-07-30-connect-share-singleplayer.md | 89 +++++++++++++------ .../2026-07-30-connect-share-mod-design.md | 9 ++ gradle.properties | 3 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 23 ++++- share/AGENTS.md | 66 ++++++++++++++ share/common/build.gradle.kts | 30 +++++++ .../com/minekube/connect/share/ShareBuild.kt | 6 ++ .../minekube/connect/share/BuildPinsTest.kt | 12 +++ share/fabric-1.21.11/build.gradle.kts | 51 +++++++++++ share/fabric-26.2/build.gradle.kts | 50 +++++++++++ share/fabric-common/build.gradle.kts | 33 +++++++ 21 files changed, 405 insertions(+), 78 deletions(-) rename core/src/main/{java/com/minekube/connect/util/Constants.java => java-templates/com/minekube/connect/util/Constants.java.peb} (89%) create mode 100644 share/AGENTS.md create mode 100644 share/common/build.gradle.kts create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareBuild.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/BuildPinsTest.kt create mode 100644 share/fabric-1.21.11/build.gradle.kts create mode 100644 share/fabric-26.2/build.gradle.kts create mode 100644 share/fabric-common/build.gradle.kts 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..75bbcb13f 100644 --- a/build-logic/src/main/kotlin/Versions.kt +++ b/build-logic/src/main/kotlin/Versions.kt @@ -40,8 +40,16 @@ 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 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 0189a5e47..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" ) 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..41a15884f 100644 --- a/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts @@ -112,5 +112,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..9596b2df2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `java-library` id("connect.build-logic") id("io.freefair.lombok") version "8.6" 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/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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index bd9fe825e..f8522e3a3 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -38,7 +38,8 @@ This plan is the independently testable singleplayer-through-Connect slice. It e - `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/libp2p versions. +- `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 @@ -98,6 +99,7 @@ This plan is the independently testable singleplayer-through-Connect slice. It e **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` @@ -110,9 +112,9 @@ This plan is the independently testable singleplayer-through-Connect slice. It e **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`, and `loomVersion`. +- 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`. -- [ ] **Step 1: Write the failing build-pin test** +- [x] **Step 1: Write the failing build-pin test** ```kotlin package com.minekube.connect.share @@ -140,7 +142,7 @@ object ShareBuild { } ``` -- [ ] **Step 2: Add the exact Gradle pins and project includes** +- [x] **Step 2: Add the exact Gradle pins and project includes** Add these constants to `Versions.kt`: @@ -152,6 +154,7 @@ 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" ``` @@ -160,8 +163,8 @@ Add `maven("https://maven.fabricmc.net/")` to dependency and plugin repositories ```kotlin include(":share:common") include(":share:fabric-common") -include(":share:fabric-1.21.11") -include(":share:fabric-26.2") +include(":share:fabric-1-21-11") +include(":share:fabric-26-2") ``` Set the wrapper URL exactly: @@ -170,26 +173,42 @@ Set the wrapper URL exactly: distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip ``` -- [ ] **Step 3: Keep plugin and Fabric conventions separate** +Give the combined Loom-remap and plugin-shadow build enough heap: -In root `build.gradle.kts`, define: +```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 fabricProjects = setOf( - projects.share.common, - projects.share.fabricCommon, - projects.share.fabric12111, - projects.share.fabric262, -).map { it.dependencyProject } +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 `this !in fabricProjects`. 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. +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") @@ -201,6 +220,9 @@ The `share/fabric-common` dependencies are: 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}") @@ -223,19 +245,28 @@ implementation(projects.share.common) implementation(projects.share.fabricCommon) ``` -The 26.2 block uses `minecraft("com.mojang:minecraft:26.2")`, no mappings dependency, and `Versions.fabricApi262Version`. +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. -- [ ] **Step 4: Run the new test and both empty mod builds** +- [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 +./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. -- [ ] **Step 5: Run the existing plugin build** +- [x] **Step 5: Run the existing plugin build** Run: @@ -245,7 +276,7 @@ Run: 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`. -- [ ] **Step 6: Commit** +- [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 @@ -913,7 +944,7 @@ git commit -m "feat: add embedded Fabric Connect ingress" Run: ```bash -./gradlew :share:fabric-1.21.11:genSources +./gradlew :share:fabric-1-21-11:genSources ``` Confirm the official mapped members used by this task exist: @@ -984,7 +1015,7 @@ For passthrough Connect sessions it lets vanilla resolve online/offline login, t Run: ```bash -./gradlew :share:fabric-1.21.11:test :share:fabric-1.21.11:runServer --args='nogui' +./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. @@ -1012,7 +1043,7 @@ git commit -m "feat: bridge Connect into 1.21.11 singleplayer" Run: ```bash -./gradlew :share:fabric-26.2:genSources +./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`. @@ -1040,7 +1071,7 @@ Repeat the explicit loopback, captured initializer, `LocalServerChannelWrapper`, 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 +./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. @@ -1126,7 +1157,7 @@ Each `fabric.mod.json` declares client environment, Kotlin entrypoint, exact Min Run: ```bash -./gradlew :share:fabric-common:test :share:fabric-1.21.11:build :share:fabric-26.2:build +./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. @@ -1184,7 +1215,7 @@ Construct failures containing endpoint tokens, invitations, and direct candidate 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' +./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. @@ -1224,7 +1255,7 @@ share-1-21-11: java-version: "21" cache: gradle - uses: gradle/actions/setup-gradle@v4 - - run: ./gradlew :share:fabric-1.21.11:build + - run: ./gradlew :share:fabric-1-21-11:build share-26-2: runs-on: ubuntu-latest @@ -1238,7 +1269,7 @@ share-26-2: java-version: "25" cache: gradle - uses: gradle/actions/setup-gradle@v4 - - run: ./gradlew :share:fabric-26.2:build + - 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. @@ -1262,7 +1293,7 @@ Document exact checks: Run: ```bash -./gradlew :core:test :share:common:test :share:fabric-common:test :share:fabric-1.21.11:build :share:fabric-26.2:build +./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 ``` 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 index 9b1339301..8799ee3f1 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -111,12 +111,21 @@ 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 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..04dfa74cc 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,22 @@ dependencyResolutionManagement { pluginManagement { repositories { + maven("https://maven.fabricmc.net/") { + name = "Fabric" + } 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("org.jetbrains.kotlin.jvm") version "2.4.10" } includeBuild("build-logic") } @@ -85,3 +96,9 @@ include(":core") include(":bungee") include(":spigot") include(":velocity") +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-26-2") +project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") diff --git a/share/AGENTS.md b/share/AGENTS.md new file mode 100644 index 000000000..090dd4704 --- /dev/null +++ b/share/AGENTS.md @@ -0,0 +1,66 @@ +# 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. diff --git a/share/common/build.gradle.kts b/share/common/build.gradle.kts new file mode 100644 index 000000000..3527d1327 --- /dev/null +++ b/share/common/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) +} + +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/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/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/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts new file mode 100644 index 000000000..8748a5c7c --- /dev/null +++ b/share/fabric-1.21.11/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("net.fabricmc.fabric-loom-remap") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-1.21.11" +} + +java { + 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\\..*") } + } +} + +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) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts new file mode 100644 index 000000000..5a233de29 --- /dev/null +++ b/share/fabric-26.2/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("net.fabricmc.fabric-loom") + id("org.jetbrains.kotlin.jvm") +} + +base { + archivesName = "connect-share-fabric-26.2" +} + +java { + 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\\..*") } + } +} + +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) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts new file mode 100644 index 000000000..74b60c147 --- /dev/null +++ b/share/fabric-common/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + jvmToolchain(21) +} + +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.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() +} From 8547fc50b7103391a24ae6b923c19d8e579167aa Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:37:23 +0200 Subject: [PATCH 098/188] refactor: share endpoint token persistence --- .../connect/identity/EndpointTokenStore.java | 151 ++++++++++++++++++ .../minekube/connect/module/CommonModule.java | 64 ++------ .../identity/EndpointTokenStoreTest.java | 104 ++++++++++++ .../connect/module/CommonModuleTest.java | 8 +- .../2026-07-30-connect-share-singleplayer.md | 12 +- 5 files changed, 276 insertions(+), 63 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/identity/EndpointTokenStore.java create mode 100644 core/src/test/java/com/minekube/connect/identity/EndpointTokenStoreTest.java 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 14ff93bbb..3d0ab88e9 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -25,10 +25,6 @@ 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; @@ -47,6 +43,7 @@ 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; @@ -56,14 +53,8 @@ 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 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; @@ -144,19 +135,19 @@ public BedrockPrincipalReadiness bedrockPrincipalReadiness(ConfigHolder configHo 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 @@ -203,37 +194,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/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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index f8522e3a3..c031150f4 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -295,7 +295,7 @@ git commit -m "build: add multi-version Fabric Share modules" - Consumes: `Utils.randomSecureString(20)` and Gson. - Produces: `EndpointTokenStore.load(Path, Map)`, `loadOrCreate(Path, Map)`, `save(Path,String)`, `generate()`, and `redact(String)`. -- [ ] **Step 1: Write failing token-store tests** +- [x] **Step 1: Write failing token-store tests** Cover these exact cases: @@ -316,7 +316,7 @@ assertEquals(token, new Gson().fromJson(Files.readString(file), JsonObject.class assertFalse(EndpointTokenStore.redact(token).contains(token)); ``` -- [ ] **Step 2: Run the focused test and observe failure** +- [x] **Step 2: Run the focused test and observe failure** Run: @@ -326,7 +326,7 @@ Run: Expected: compilation fails because `EndpointTokenStore` does not exist. -- [ ] **Step 3: Implement the store** +- [x] **Step 3: Implement the store** `EndpointTokenStore` must: @@ -344,7 +344,7 @@ public final class EndpointTokenStore { `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. -- [ ] **Step 4: Make CommonModule use the shared store** +- [x] **Step 4: Make CommonModule use the shared store** Replace the private `CommonModule.Token` class with an injected/provider-created `EndpointTokenStore` and: @@ -356,7 +356,7 @@ return endpointTokenStore.loadOrCreate( Keep the existing `CommonModuleTest.connectTokenIsPersistedForAllConnectClients` green. -- [ ] **Step 5: Run token and core tests** +- [x] **Step 5: Run token and core tests** Run: @@ -366,7 +366,7 @@ Run: Expected: all focused tests pass. -- [ ] **Step 6: Commit** +- [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 From cfcc5b1ce5f41dc068a61cfdd3c8024667bf5107 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:53:25 +0200 Subject: [PATCH 099/188] feat: persist and import Share endpoint identities --- .../2026-07-30-connect-share-singleplayer.md | 64 ++- .../share/identity/EndpointIdentity.kt | 52 ++ .../share/identity/EndpointIdentityStore.kt | 505 ++++++++++++++++++ .../identity/EndpointIdentityStoreTest.kt | 339 ++++++++++++ share/fabric-common/build.gradle.kts | 1 + .../share/fabric/RandomEndpointNameSource.kt | 80 +++ .../WatchEndpointCredentialValidator.kt | 131 +++++ .../fabric/RandomEndpointNameSourceTest.kt | 70 +++ .../WatchEndpointCredentialValidatorTest.kt | 186 +++++++ 9 files changed, 1406 insertions(+), 22 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentity.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/identity/EndpointIdentityStore.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/identity/EndpointIdentityStoreTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSource.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidator.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/RandomEndpointNameSourceTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/WatchEndpointCredentialValidatorTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index c031150f4..2194e0ae4 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -401,15 +401,23 @@ fun interface EndpointNameSource { suspend fun create(): String } fun interface EndpointCredentialValidator { - suspend fun validate(identity: EndpointIdentity): CredentialValidation + suspend fun validate( + identity: EndpointIdentity, + ): Either } -sealed interface CredentialValidation { - data object Valid : CredentialValidation - data class Invalid(val safeMessage: String) : CredentialValidation +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 } ``` -- [ ] **Step 1: Write the identity-store tests** +- [x] **Step 1: Write the identity-store tests** Tests must prove: @@ -422,11 +430,13 @@ Tests must prove: @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`. -- [ ] **Step 2: Run and observe the missing-type failure** +- [x] **Step 2: Run and observe the missing-type failure** Run: @@ -436,7 +446,7 @@ Run: Expected: compilation fails on `EndpointIdentityStore`. -- [ ] **Step 3: Implement exact persistence semantics** +- [x] **Step 3: Implement exact persistence semantics** `EndpointIdentityStore` has this constructor and public API: @@ -452,33 +462,39 @@ class EndpointIdentityStore( endpoint: String, token: String, validator: EndpointCredentialValidator, - ): CredentialValidation + ): Either suspend fun importTokenFile( endpoint: String, tokenFile: Path, validator: EndpointCredentialValidator, - ): CredentialValidation - suspend fun resetConfirmed(): EndpointIdentity + ): Either + suspend fun resetConfirmed(): Either } ``` Use `config.json` with: ```json -{"endpoint":"amber-fox","credentialSource":"IMPORTED"} +{"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 `CredentialValidation.Valid`, then atomically replaces token first and config second while retaining backups until both moves succeed. Restore both backups when the second move fails. +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 file names. `currentOrCreate()` calls -`recoverInterruptedTransaction()` before reading identity files. When the -journal exists, restore both backups, or remove both partially created files -when no prior identity existed, then delete the journal. Delete backups and the -journal only after both final files are durable. A process crash during either -move therefore rolls back on the next load. +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. -- [ ] **Step 4: Write validator tests against MockWebServer** +- [x] **Step 4: Write validator tests against MockWebServer** Assert that a validation request sends: @@ -488,9 +504,13 @@ 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 `CredentialValidation.Invalid`; transport failure returns a safe network message. +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. -- [ ] **Step 5: Implement the Watch validator** +- [x] **Step 5: Implement the Watch validator** Expose: @@ -510,7 +530,7 @@ 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. -- [ ] **Step 6: Run focused tests** +- [x] **Step 6: Run focused tests** Run: 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/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/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts index 74b60c147..71a4c5a49 100644 --- a/share/fabric-common/build.gradle.kts +++ b/share/fabric-common/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { 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("com.squareup.okhttp3:okhttp:4.9.3") testImplementation(kotlin("test")) 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/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/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/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, + ) + } +} From 020b0199d4dc13d13148c78c949bd341a856ae46 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 18:56:19 +0200 Subject: [PATCH 100/188] feat: add Share host admission policy --- .../2026-07-30-connect-share-singleplayer.md | 10 +- .../share/admission/AdmissionController.kt | 166 ++++++++++++++ .../share/admission/AdmissionIdentity.kt | 45 ++++ .../admission/AdmissionControllerTest.kt | 202 ++++++++++++++++++ 4 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 2194e0ae4..e364d4188 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -540,7 +540,7 @@ Run: Expected: identity and validation tests pass. -- [ ] **Step 7: Commit** +- [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 @@ -581,7 +581,7 @@ enum class Ingress { CONNECT, DIRECT_LAN, DIRECT_INTERNET } enum class AdmissionAnswer { ALLOW, DENY, TIMEOUT, STOPPED, CAPACITY } ``` -- [ ] **Step 1: Write failing admission tests** +- [x] **Step 1: Write failing admission tests** Cover: @@ -597,7 +597,7 @@ Cover: Use `kotlinx.coroutines.test.runTest` and a test scheduler for the 30-second timeout. -- [ ] **Step 2: Run and observe failure** +- [x] **Step 2: Run and observe failure** Run: @@ -607,7 +607,7 @@ Run: Expected: missing admission types. -- [ ] **Step 3: Implement AdmissionController** +- [x] **Step 3: Implement AdmissionController** Expose: @@ -628,7 +628,7 @@ class AdmissionController( 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. -- [ ] **Step 4: Run tests** +- [x] **Step 4: Run tests** Run: 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..c4581b50b --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionController.kt @@ -0,0 +1,166 @@ +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 lock = Any() + private val requests = linkedMapOf() + private val authenticatedApprovals = mutableSetOf() + 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): AdmissionAnswer { + val lookup = synchronized(lock) { + val key = identity.admissionKey() + requests[key]?.let { + return@synchronized RequestLookup.Await(it, startTimeout = false) + } + if (connectedCount() >= maxGuests()) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) + } + if ( + identity is AdmissionIdentity.Authenticated && + identity.uuid in authenticatedApprovals + ) { + 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, + ), + ) + 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) + } + lookup.request.answer.await() + } + } + } + + 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) { + val identity = entry.value.pending.identity + if (identity is AdmissionIdentity.Authenticated) { + authenticatedApprovals += identity.uuid + } + } + publishPending() + entry.value + } + complete(completed, answer) + } + + fun resetShare() { + val stopped = synchronized(lock) { + val current = requests.values.toList() + requests.clear() + authenticatedApprovals.clear() + publishPending() + current + } + stopped.forEach { + complete(it, AdmissionAnswer.STOPPED) + } + } + + 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 publishPending() { + mutablePending.value = requests.values.map(PendingRequest::pending) + } + + private fun AdmissionIdentity.admissionKey(): AdmissionKey = when (this) { + is AdmissionIdentity.Authenticated -> AdmissionKey.Authenticated(uuid) + is AdmissionIdentity.UnverifiedOffline -> AdmissionKey.Unverified(connectionId) + } + + private sealed interface AdmissionKey { + data class Authenticated(val uuid: UUID) : AdmissionKey + data class Unverified(val connectionId: String) : AdmissionKey + } + + private class PendingRequest( + val key: AdmissionKey, + val pending: PendingAdmission, + val answer: CompletableDeferred = CompletableDeferred(), + val timeoutJob: AtomicReference = AtomicReference(), + ) + + 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..6b07dc41e --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/admission/AdmissionIdentity.kt @@ -0,0 +1,45 @@ +package com.minekube.connect.share.admission + +import java.util.UUID + +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, +} + +data class PendingAdmission( + val requestId: UUID, + val identity: AdmissionIdentity, +) 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..0e435d768 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/admission/AdmissionControllerTest.kt @@ -0,0 +1,202 @@ +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.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class AdmissionControllerTest { + @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()) + } + + private fun kotlinx.coroutines.test.TestScope.controller( + connectedCount: () -> Int = { 0 }, + maxGuests: () -> Int = { 8 }, + ) = AdmissionController( + scope = backgroundScope, + timeout = 30.seconds, + maxPending = 16, + connectedCount = connectedCount, + maxGuests = maxGuests, + ) + + 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") + } +} From 6bdb13b787e8df5f9e0d81dc44d8dcb38c4590cc Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:02:27 +0200 Subject: [PATCH 101/188] feat: gate Connect sessions before tunneling --- .../minekube/connect/module/CommonModule.java | 6 + .../connect/register/WatcherRegister.java | 135 +++++++++++++- .../watch/AllowAllSessionAdmissionGate.java | 16 ++ .../watch/SessionAdmissionDecision.java | 55 ++++++ .../connect/watch/SessionAdmissionGate.java | 11 ++ .../connect/register/WatcherRegisterTest.java | 164 +++++++++++++++++- .../AllowAllSessionAdmissionGateTest.java | 19 ++ .../2026-07-30-connect-share-singleplayer.md | 10 +- 8 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/watch/AllowAllSessionAdmissionGate.java create mode 100644 core/src/main/java/com/minekube/connect/watch/SessionAdmissionDecision.java create mode 100644 core/src/main/java/com/minekube/connect/watch/SessionAdmissionGate.java create mode 100644 core/src/test/java/com/minekube/connect/watch/AllowAllSessionAdmissionGateTest.java 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 3d0ab88e9..fc890d691 100644 --- a/core/src/main/java/com/minekube/connect/module/CommonModule.java +++ b/core/src/main/java/com/minekube/connect/module/CommonModule.java @@ -29,6 +29,7 @@ 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; @@ -53,6 +54,8 @@ import com.minekube.connect.util.HttpUtils; import com.minekube.connect.util.LanguageManager; import com.minekube.connect.util.Metrics; +import com.minekube.connect.watch.AllowAllSessionAdmissionGate; +import com.minekube.connect.watch.SessionAdmissionGate; import java.io.IOException; import java.nio.file.Path; import java.util.concurrent.TimeUnit; @@ -76,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 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..9c98ea732 100644 --- a/core/src/main/java/com/minekube/connect/register/WatcherRegister.java +++ b/core/src/main/java/com/minekube/connect/register/WatcherRegister.java @@ -40,6 +40,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 +51,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 +74,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 +244,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 +301,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 +336,7 @@ public void onError(Throwable t) { : " (cause: " + t.getCause().toString() + ")" ) ); + cancelPendingAdmissions(); cancelResetBackOffTimer(); retry(); } @@ -325,6 +346,7 @@ public void onCompleted() { if (!acceptTerminalEvent()) { return; } + cancelPendingAdmissions(); cancelResetBackOffTimer(); retry(); } @@ -357,6 +379,101 @@ 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()) { + reject(proposal, Status.newBuilder() + .setCode(Code.PERMISSION_DENIED_VALUE) + .setMessage(decision.getSafeMessage()) + .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/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/test/java/com/minekube/connect/register/WatcherRegisterTest.java b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java index 463000b4e..bd3ff9acb 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,14 @@ 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.minekube.connect.api.SimpleConnectApi; import com.minekube.connect.api.inject.PlatformInjector; import com.minekube.connect.api.logger.ConnectLogger; @@ -27,6 +33,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 +46,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 +411,130 @@ 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()); + }); + 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 +579,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 +606,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 +617,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/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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index e364d4188..0748ac962 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -638,7 +638,7 @@ Run: Expected: all seven cases pass. -- [ ] **Step 5: Commit** +- [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 @@ -675,7 +675,7 @@ public final class SessionAdmissionDecision { } ``` -- [ ] **Step 1: Add failing WatcherRegister tests** +- [x] **Step 1: Add failing WatcherRegister tests** Add tests that hold a `CompletableFuture` and assert: @@ -686,7 +686,7 @@ 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. -- [ ] **Step 2: Run and observe failure** +- [x] **Step 2: Run and observe failure** Run: @@ -696,7 +696,7 @@ Run: Expected: compilation fails because the gate does not exist. -- [ ] **Step 3: Implement the default gate and WatcherRegister sequencing** +- [x] **Step 3: Implement the default gate and WatcherRegister sequencing** Use Guice `OptionalBinder` in `CommonModule`: set `AllowAllSessionAdmissionGate` as the default `SessionAdmissionGate`, and let @@ -713,7 +713,7 @@ started.get() 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. -- [ ] **Step 4: Run Core tests** +- [x] **Step 4: Run Core tests** Run: From 874e0b89689eacaa86f411f0ba7e77db2e641dab Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:10:03 +0200 Subject: [PATCH 102/188] feat: add Connect Share lifecycle --- .../2026-07-30-connect-share-singleplayer.md | 24 +- .../connect/share/ConnectShareIngress.kt | 17 ++ .../connect/share/MinecraftShareBridge.kt | 12 + .../connect/share/ShareCoordinator.kt | 167 +++++++++++ .../minekube/connect/share/ShareOptions.kt | 25 ++ .../com/minekube/connect/share/ShareState.kt | 33 +++ .../connect/share/ShareCoordinatorTest.kt | 268 ++++++++++++++++++ 7 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ConnectShareIngress.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 0748ac962..d1c7ac708 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -723,7 +723,7 @@ Run: Expected: focused tests pass and existing plugin behavior remains immediate-allow. -- [ ] **Step 5: Commit** +- [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 @@ -770,7 +770,7 @@ data class ConnectShareHandle( ) ``` -- [ ] **Step 1: Write state and cleanup tests** +- [x] **Step 1: Write state and cleanup tests** Prove: @@ -781,9 +781,10 @@ Prove: @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`() ``` -- [ ] **Step 2: Run and observe missing production types** +- [x] **Step 2: Run and observe missing production types** Run: @@ -793,7 +794,7 @@ Run: Expected: compilation failure. -- [ ] **Step 3: Implement the coordinator** +- [x] **Step 3: Implement the coordinator** `ShareState` is: @@ -807,9 +808,20 @@ sealed interface ShareState { } ``` -`ShareCoordinator.start` runs under a mutex, creates the bridge, loads identity, starts Connect, and publishes `Sharing`. `stop` snapshots handles under the mutex, publishes `Stopping`, closes ingress, closes bridge, resets admission, then publishes `Idle`. Every close runs even when a previous close throws; aggregate failures into logs but keep UI messages sanitized. +`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. -- [ ] **Step 4: Run tests and commit** +- [x] **Step 4: Run tests and commit** Run: 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/MinecraftShareBridge.kt b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt new file mode 100644 index 000000000..725063aee --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt @@ -0,0 +1,12 @@ +package com.minekube.connect.share + +import java.net.SocketAddress + +data class LocalShareTarget( + val address: SocketAddress, + 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/ShareCoordinator.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt new file mode 100644 index 000000000..6de783adb --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -0,0 +1,167 @@ +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 failureReporter: (String) -> Unit = {}, +) { + private val lifecycleMutex = Mutex() + private val mutableState = MutableStateFlow(ShareState.Idle) + 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() }, + ) + val identity = identityProvider() + val connect = install( + acquire = { ingress.start(identity, target.address) }, + release = { acquired, _ -> acquired.close() }, + ) + AcquiredShare(target, connect) + } + val (acquired, release) = managedShare.allocateSafely() + val sharing = ShareState.Sharing( + endpoint = acquired.connect.endpoint, + address = acquired.connect.publicAddress, + ) + active = ActiveShare(release) + 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() + + private data class AcquiredShare( + val target: LocalShareTarget, + val connect: ConnectShareHandle, + ) + + private data class ActiveShare( + val release: suspend (ExitCase) -> Unit, + ) + + @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" + } +} 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..b898bc470 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt @@ -0,0 +1,25 @@ +package com.minekube.connect.share + +data class ShareOptions( + val gameMode: ShareGameMode, + val allowCheats: Boolean, + val maxGuests: Int = 8, +) { + 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..0a623d2e8 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt @@ -0,0 +1,33 @@ +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, + ) : ShareState + + 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/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..afe3acae6 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -0,0 +1,268 @@ +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 `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 `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 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, + 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" + }, + 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 = InetSocketAddress.createUnresolved("127.0.0.1", 25565), + close = { + events += "bridge-close" + }, + ) + } + val ingress = ConnectShareIngress { identity, target -> + val handle = ingressStart(identity, target) + handle.copy(close = ingressClose) + } + return Fixture( + coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityProvider, + admission = admission, + 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 IDENTITY = EndpointIdentity( + endpoint = "amber-fox", + token = "T-AAAAAAAAAAAAAAAAAAAA", + endpointSource = CredentialSource.GENERATED, + tokenSource = CredentialSource.GENERATED, + ) + } +} From 54ff956cffe3058f4d59cabeacd00c3d59354c34 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:20:31 +0200 Subject: [PATCH 103/188] feat: add embedded Fabric Connect ingress --- .../com/minekube/connect/ConnectPlatform.java | 70 ++++-- .../connect/config/ConnectConfig.java | 29 ++- .../connect/EmbeddedConnectPlatformTest.java | 203 ++++++++++++++++++ .../2026-07-30-connect-share-singleplayer.md | 16 +- share/fabric-common/build.gradle.kts | 1 + .../share/fabric/FabricConnectIngress.kt | 198 +++++++++++++++++ .../share/fabric/FabricPlatformUtils.kt | 16 ++ .../fabric/FabricSessionAdmissionGate.kt | 152 +++++++++++++ .../share/fabric/FabricConnectIngressTest.kt | 107 +++++++++ .../fabric/FabricSessionAdmissionGateTest.kt | 178 +++++++++++++++ 10 files changed, 945 insertions(+), 25 deletions(-) create mode 100644 core/src/test/java/com/minekube/connect/EmbeddedConnectPlatformTest.java create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricPlatformUtils.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricConnectIngressTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt 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/config/ConnectConfig.java b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java index b7631b406..2577313a9 100644 --- a/core/src/main/java/com/minekube/connect/config/ConnectConfig.java +++ b/core/src/main/java/com/minekube/connect/config/ConnectConfig.java @@ -29,6 +29,9 @@ 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; /** @@ -37,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; @@ -48,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. @@ -75,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; 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/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index d1c7ac708..8997a50c2 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -855,7 +855,7 @@ git commit -m "feat: add Connect Share lifecycle" - 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`. -- [ ] **Step 1: Write failing embedded-platform tests** +- [x] **Step 1: Write failing embedded-platform tests** Assert: @@ -867,7 +867,7 @@ 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. -- [ ] **Step 2: Add the embedded Core entry point** +- [x] **Step 2: Add the embedded Core entry point** Add: @@ -887,7 +887,7 @@ public void initEmbedded( Share the common initialization tail with the existing `init`; do not change plugin config loading. -- [ ] **Step 3: Implement the Kotlin admission adapter** +- [x] **Step 3: Implement the Kotlin admission adapter** `FabricSessionAdmissionGate.request` maps: @@ -914,7 +914,7 @@ offline profile to Ingress.CONNECT)`. It completes before vanilla moves the connection into configuration/play state. -- [ ] **Step 4: Implement FabricConnectIngress** +- [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: @@ -926,7 +926,9 @@ allowOfflineModePlayers = true ``` Use the already persisted `token.json`; do not generate or write credentials -inside `start`. Return the `ConnectShareHandle` defined in Task 6: +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( @@ -938,7 +940,7 @@ ConnectShareHandle( where `publicAddress` is `.play.minekube.net`. -- [ ] **Step 5: Run focused and Core regression tests** +- [x] **Step 5: Run focused and Core regression tests** Run: @@ -948,7 +950,7 @@ Run: Expected: embedded lifecycle and admission mapping pass. -- [ ] **Step 6: Commit** +- [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 diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts index 71a4c5a49..000e4d29b 100644 --- a/share/fabric-common/build.gradle.kts +++ b/share/fabric-common/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { 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")) 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..b9ec3d0e1 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricConnectIngress.kt @@ -0,0 +1,198 @@ +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.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 scope: CoroutineScope, + private val runtimeFactory: FabricConnectRuntimeFactory, +) : ConnectShareIngress { + constructor( + dataDirectory: Path, + platformInjector: CommonPlatformInjector, + logger: ConnectLogger, + platformUtils: FabricPlatformUtils, + admission: AdmissionController, + scope: CoroutineScope, + ) : this( + dataDirectory = dataDirectory, + admission = admission, + scope = scope, + 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) + 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, + ) = FabricConnectIngress( + dataDirectory = dataDirectory, + admission = admission, + scope = scope, + 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 { + platform.disable() + } + } catch (failure: Throwable) { + try { + platform.disable() + } catch (cleanupFailure: Throwable) { + failure.addSuppressed(cleanupFailure) + } + 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/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..6014919b3 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGate.kt @@ -0,0 +1,152 @@ +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.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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class FabricSessionAdmissionGate( + private val admission: AdmissionController, + private val scope: CoroutineScope, +) : SessionAdmissionGate { + private val stopped = AtomicBoolean() + private val active = ConcurrentHashMap, Job>() + + override fun request( + proposal: SessionProposal, + ): CompletionStage { + 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 { + future.complete(admission.request(identity).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 + } + + 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 -> SessionAdmissionDecision.deny("Host denied this connection") + AdmissionAnswer.TIMEOUT -> SessionAdmissionDecision.deny("Host approval timed out") + AdmissionAnswer.STOPPED -> SessionAdmissionDecision.deny("Sharing stopped") + AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny("Share is full") + } + + 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" + } +} + +class FabricLocalLoginAdmission( + private val admission: AdmissionController, +) { + suspend fun request( + name: String, + uuid: UUID, + connectionId: String, + minecraftAuthenticated: Boolean, + ): AdmissionAnswer { + val identity = if (minecraftAuthenticated) { + AdmissionIdentity.Authenticated( + name = name, + uuid = uuid, + source = AuthSource.MOJANG, + ) + } else { + AdmissionIdentity.UnverifiedOffline( + name = name, + uuid = uuid, + connectionId = connectionId, + ingress = Ingress.CONNECT, + ) + } + return admission.request(identity) + } +} 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/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt new file mode 100644 index 000000000..885a75a03 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt @@ -0,0 +1,178 @@ +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.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 `Connect authenticated profile waits for host approval`() = runTest { + val admission = admission() + val gate = FabricSessionAdmissionGate(admission, backgroundScope) + + 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) + } + + @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("Host denied this connection", decision.safeMessage) + } + + @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 local = FabricLocalLoginAdmission(admission) + val authenticated = async { + local.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-authenticated", + minecraftAuthenticated = true, + ) + } + runCurrent() + val authenticatedIdentity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals(AuthSource.MOJANG, authenticatedIdentity.source) + admission.answer(admission.pending.value.single().requestId, allow = true) + assertEquals(AdmissionAnswer.ALLOW, authenticated.await()) + + val offline = async { + local.request( + name = "Alex", + uuid = PLAYER_UUID, + connectionId = "connection-offline", + minecraftAuthenticated = false, + ) + } + runCurrent() + val offlineIdentity = assertIs( + admission.pending.value.single().identity, + ) + assertEquals("connection-offline", offlineIdentity.connectionId) + assertEquals(Ingress.CONNECT, offlineIdentity.ingress) + 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") + } +} From 45c6440e1eb17f88bb2d4630cd4d2c786d85a9be Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:37:18 +0200 Subject: [PATCH 104/188] feat: bridge Connect into 1.21.11 singleplayer --- .../2026-07-30-connect-share-singleplayer.md | 12 +- share/fabric-1.21.11/build.gradle.kts | 7 + .../v1_21_11/mixin/ConnectionAccessor.java | 12 ++ .../mixin/IntegratedServerAccessor.java | 19 ++ .../v1_21_11/mixin/IntegratedServerMixin.java | 24 +++ .../mixin/LanServerPingerAccessor.java | 12 ++ .../ServerConnectionListenerAccessor.java | 13 ++ .../mixin/ServerConnectionListenerMixin.java | 57 ++++++ .../mixin/ServerLoginPacketListenerMixin.java | 117 +++++++++++ .../v1_21_11/CapturedServerTransport.kt | 108 ++++++++++ .../v1_21_11/ConnectGameProfileMapper.kt | 52 +++++ .../fabric/v1_21_11/Minecraft12111Bridge.kt | 187 +++++++++++++++++ .../v1_21_11/Minecraft12111LoginAdmission.kt | 42 ++++ .../VanillaMinecraft12111Transport.kt | 192 ++++++++++++++++++ .../connect-share-fabric-1.21.11.mixins.json | 20 ++ .../src/main/resources/fabric.mod.json | 17 ++ .../v1_21_11/CapturedServerTransportTest.kt | 57 ++++++ .../v1_21_11/ConnectGameProfileMapperTest.kt | 47 +++++ .../v1_21_11/Minecraft12111BridgeTest.kt | 125 ++++++++++++ .../fabric/FabricSessionAdmissionGate.kt | 73 +++++++ .../FabricLocalLoginAdmissionGateTest.kt | 82 ++++++++ 21 files changed, 1269 insertions(+), 6 deletions(-) create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ConnectionAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt create mode 100644 share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json create mode 100644 share/fabric-1.21.11/src/main/resources/fabric.mod.json create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 8997a50c2..760f7e20a 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -973,7 +973,7 @@ git commit -m "feat: add embedded Fabric Connect ingress" - Consumes: `IntegratedServer.publishServer`, `ServerConnectionListener.startTcpServerListener`, `LocalServerChannelWrapper`, and Connect channel attributes. - Produces: `Minecraft12111Bridge : MinecraftShareBridge`. -- [ ] **Step 1: Generate and inspect exact 1.21.11 sources** +- [x] **Step 1: Generate and inspect exact 1.21.11 sources** Run: @@ -993,7 +993,7 @@ 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. -- [ ] **Step 2: Write the bridge test before mixins** +- [x] **Step 2: Write the bridge test before mixins** Use a fake captured transport and assert: @@ -1006,7 +1006,7 @@ assertEquals(0, capturedListenerCountAfterClose) Opening twice after close must succeed; opening while active must fail without adding a second listener. -- [ ] **Step 3: Capture vanilla's child initializer and force loopback** +- [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: @@ -1022,7 +1022,7 @@ It must leave ordinary vanilla publishing unchanged unless `CapturedServerTransp Minecraft's `Connection` so the login mixin can read Connect's channel attribute without reflection. -- [ ] **Step 4: Bind the local channel and implement stop** +- [x] **Step 4: Bind the local channel and implement stop** After `publishServer`, identify exactly one newly added loopback `ChannelFuture`. Bind: @@ -1038,13 +1038,13 @@ ServerBootstrap() 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. -- [ ] **Step 5: Inject Connect-authenticated login profiles** +- [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. -- [ ] **Step 6: Run adapter tests and a headless launch smoke** +- [x] **Step 6: Run adapter tests and a headless launch smoke** Run: diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 8748a5c7c..7d7c82a1f 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -49,3 +49,10 @@ dependencies { tasks.test { useJUnitPlatform() } + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} 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..c274069e2 --- /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.fabric.v1_21_11.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/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..60806e753 --- /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.fabric.v1_21_11.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..569a4199f --- /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,117 @@ +package com.minekube.connect.share.fabric.v1_21_11.mixin; + +import com.mojang.authlib.GameProfile; +import com.minekube.connect.api.ConnectAttributes; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.network.netty.LocalSession; +import com.minekube.connect.share.admission.AdmissionAnswer; +import com.minekube.connect.share.fabric.v1_21_11.ConnectGameProfileMapper; +import com.minekube.connect.share.fabric.v1_21_11.Minecraft12111LoginAdmission; +import io.netty.channel.Channel; +import java.util.concurrent.CompletableFuture; +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) { + Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); + ConnectPlayer player = channel.attr(ConnectAttributes.CONNECT_PLAYER).get(); + if (player == null) { + return; + } + + GameProfile profile = + ConnectGameProfileMapper.toMinecraftOrNull(player.getGameProfile()); + if (profile == null || !hello.name().equalsIgnoreCase(profile.name())) { + 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) { + Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); + LocalSession.Context context = LocalSession.context(channel).orElse(null); + if (context == null || !context.getPlayer().getAuth().isPassthrough()) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + CompletableFuture decision = Minecraft12111LoginAdmission.request( + profile.name(), + profile.id(), + context.getPlayer().getSessionId(), + server.usesAuthentication() && !connection.isMemoryConnection()) + .toCompletableFuture(); + channel.closeFuture().addListener(ignored -> decision.cancel(false)); + decision.whenComplete((answer, failure) -> server.execute(() -> { + if (!connection.isConnected()) { + return; + } + if (failure != null || answer != AdmissionAnswer.ALLOW) { + disconnect(connectShare$denialReason(answer)); + return; + } + connectShare$admissionAllowed = true; + })); + } + + @Unique + private Component connectShare$denialReason(@Nullable AdmissionAnswer answer) { + if (answer == AdmissionAnswer.TIMEOUT) { + return Component.literal("Host approval timed out"); + } + if (answer == AdmissionAnswer.CAPACITY) { + return Component.literal("This share is full"); + } + if (answer == AdmissionAnswer.STOPPED) { + return Component.literal("Sharing stopped"); + } + return Component.literal("Host denied this connection"); + } +} diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt new file mode 100644 index 000000000..b879f5296 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt @@ -0,0 +1,108 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +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 { + synchronized(captureLock) { + armed + ?.takeIf { it.owner === Thread.currentThread() } + ?.childInitializer = initializer + } + return initializer + } + + @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/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..f67e08a4b --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectGameProfileMapper.kt @@ -0,0 +1,52 @@ +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.google.common.collect.ArrayListMultimap +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.mojang.authlib.properties.PropertyMap +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 = ArrayListMultimap.create() + source.properties.forEach { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + val mapped = if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + properties.put(property.name, mapped) + } + GameProfile( + source.uniqueId, + source.username, + PropertyMap(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/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..f7399f833 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111Bridge.kt @@ -0,0 +1,187 @@ +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.minekube.connect.inject.CommonPlatformInjector +import com.minekube.connect.share.LocalShareTarget +import com.minekube.connect.share.MinecraftShareBridge +import com.minekube.connect.share.ShareOptions +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.channel.local.LocalAddress +import java.net.InetSocketAddress +import java.net.SocketAddress + +class Minecraft12111Bridge internal constructor( + private val transport: Minecraft12111Transport, + private val localBinder: LocalShareChannelBinder, + private val loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : CommonPlatformInjector(), MinecraftShareBridge { + constructor() : this( + VanillaMinecraft12111Transport(), + NettyLocalShareChannelBinder(), + ) + + constructor( + loginAdmissionFactory: () -> FabricLocalLoginAdmissionGate, + ) : this( + VanillaMinecraft12111Transport(), + NettyLocalShareChannelBinder(), + loginAdmissionFactory, + ) + + 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 admission: AutoCloseable? = null + try { + validatePublished(published).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + local = localBinder.bind(published.childInitializer) + validateLocal(local).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + published.addLocalListener(local) + localAdded = true + admission = loginAdmissionFactory + ?.invoke() + ?.let(Minecraft12111LoginAdmission::install) + val acquired = ActiveTransport(published, local, admission) + active = acquired + serverSocketAddress = local.address + LocalShareTarget(local.address) { + close(acquired) + } + } catch (failure: Throwable) { + admission?.close() + if (localAdded) { + published.removeLocalListener(checkNotNull(local)) + } + local?.close() + published.close() + throw failure + } + } + + override fun inject(): Boolean = synchronized(lifecycleLock) { + active != null + } + + override fun isInjected(): Boolean = synchronized(lifecycleLock) { + active != null + } + + override fun shutdown() { + synchronized(lifecycleLock) { + active?.stopAdmission() + active?.closeLocal() + } + } + + 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 class ActiveTransport( + private val published: PublishedMinecraftTransport, + private val local: LocalShareChannel, + private val admission: AutoCloseable?, + ) { + private var admissionStopped = false + private var localClosed = false + + fun stopAdmission() { + if (admissionStopped) { + return + } + admissionStopped = true + admission?.close() + } + + fun closeLocal() { + if (localClosed) { + return + } + localClosed = true + published.removeLocalListener(local) + local.close() + } + + fun close() { + stopAdmission() + closeLocal() + published.close() + } + } +} + +internal fun interface Minecraft12111Transport { + fun publish(options: ShareOptions): PublishedMinecraftTransport +} + +internal interface PublishedMinecraftTransport { + val address: InetSocketAddress + val childInitializer: ChannelInitializer + + fun addLocalListener(listener: LocalShareChannel) + + fun removeLocalListener(listener: LocalShareChannel) + + fun close() +} + +internal fun interface LocalShareChannelBinder { + fun bind(childInitializer: ChannelInitializer): LocalShareChannel +} + +internal interface LocalShareChannel { + val address: SocketAddress + + fun close() +} + +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/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt new file mode 100644 index 000000000..8a7ee5988 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt @@ -0,0 +1,42 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.atomic.AtomicReference + +object Minecraft12111LoginAdmission { + 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, + ): CompletionStage { + val gate = installed.get() + if (gate == null) { + return CompletableFuture.completedFuture(AdmissionAnswer.STOPPED) + } + return gate.request( + name = name, + uuid = uuid, + connectionId = connectionId, + minecraftAuthenticated = minecraftAuthenticated, + ) + } +} 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..5044a4bfb --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/VanillaMinecraft12111Transport.kt @@ -0,0 +1,192 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.network.netty.LocalServerChannelWrapper +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.bootstrap.ServerBootstrap +import io.netty.channel.Channel +import io.netty.channel.ChannelFuture +import io.netty.channel.ChannelInitializer +import io.netty.channel.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import io.netty.util.concurrent.DefaultThreadFactory +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, + ) + } 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, +) : PublishedMinecraftTransport { + override fun addLocalListener(listener: LocalShareChannel) { + val future = (listener as NettyLocalShareChannel).future + synchronized(channels) { + check(channels.add(future)) { + "Minecraft already tracks the Connect Share local listener" + } + } + } + + override fun removeLocalListener(listener: LocalShareChannel) { + val future = (listener as NettyLocalShareChannel).future + synchronized(channels) { + channels.remove(future) + } + } + + override fun close() { + synchronized(channels) { + channels.remove(loopback) + } + loopback.closeChannel() + (server as IntegratedServerAccessor).setConnectSharePublishedPort(-1) + } +} + +internal class NettyLocalShareChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + ): LocalShareChannel { + val eventLoop = DefaultEventLoopGroup( + 0, + DefaultThreadFactory( + "Connect Share local", + Thread.MAX_PRIORITY, + ), + ) + try { + val future = ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(childInitializer) + .group(eventLoop) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() + return NettyLocalShareChannel(future, eventLoop) + } catch (failure: Throwable) { + eventLoop.shutdownGracefully().syncUninterruptibly() + throw failure + } + } +} + +private class NettyLocalShareChannel( + val future: ChannelFuture, + private val eventLoop: EventLoopGroup, +) : LocalShareChannel { + override val address = future.channel().localAddress() + + override fun close() { + future.closeChannel() + eventLoop.shutdownGracefully().syncUninterruptibly() + } +} + +private fun ChannelFuture.closeChannel() { + if (channel().isOpen) { + channel().close().syncUninterruptibly() + } +} 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..12dcd00a3 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/connect-share-fabric-1.21.11.mixins.json @@ -0,0 +1,20 @@ +{ + "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" + ], + "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..703d736bb --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "connect_share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "*", + "mixins": [ + "connect-share-fabric-1.21.11.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "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..e12733288 --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransportTest.kt @@ -0,0 +1,57 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +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.assertSame +import kotlin.test.assertTrue + +class CapturedServerTransportTest { + @Test + fun `captures the exact vanilla initializer and group only for the armed thread`() { + val initializer = NoopInitializer + val group = DefaultEventLoopGroup(1) + try { + val lease = CapturedServerTransport.arm() + + assertTrue(CapturedServerTransport.isShareStartArmed()) + assertSame( + initializer, + CapturedServerTransport.captureChildInitializer(initializer), + ) + assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) + + var otherThreadArmed = true + thread { + otherThreadArmed = CapturedServerTransport.isShareStartArmed() + }.join() + + val captured = lease.complete().getOrNull() + requireNotNull(captured) + assertSame(initializer, 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/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..4d51c7893 --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111BridgeTest.kt @@ -0,0 +1,125 @@ +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.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 + + 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 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, + ): 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-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 index 6014919b3..68f9a6b4f 100644 --- 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 @@ -150,3 +150,76 @@ class FabricLocalLoginAdmission( return admission.request(identity) } } + +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, + ): 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, + ), + ) + } 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/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..4bb5ce895 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricLocalLoginAdmissionGateTest.kt @@ -0,0 +1,82 @@ +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 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 `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, + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + val identity = assertIs(pending.identity) + assertEquals("connection-1", identity.connectionId) + 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") + } +} From 533feec0bc26dc5c75a5a68928a303f92598467a Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 19:49:51 +0200 Subject: [PATCH 105/188] feat: bridge Connect into 26.2 singleplayer --- .../2026-07-30-connect-share-singleplayer.md | 10 +- .../connect/share}/CapturedServerTransport.kt | 2 +- .../connect/share/VersionedMinecraftBridge.kt | 261 ++++++++++++++++++ .../connect/share/AdapterContractTest.kt | 136 +++++++++ .../v1_21_11/mixin/IntegratedServerMixin.java | 2 +- .../mixin/ServerConnectionListenerMixin.java | 2 +- .../mixin/ServerLoginPacketListenerMixin.java | 60 +--- .../fabric/v1_21_11/Minecraft12111Bridge.kt | 197 ++----------- .../v1_21_11/Minecraft12111LoginBridge.kt | 90 ++++++ .../VanillaMinecraft12111Transport.kt | 51 +--- .../v1_21_11/CapturedServerTransportTest.kt | 1 + share/fabric-26.2/build.gradle.kts | 7 + .../v26_2/mixin/ConnectionAccessor.java | 12 + .../v26_2/mixin/IntegratedServerAccessor.java | 16 ++ .../v26_2/mixin/IntegratedServerMixin.java | 24 ++ .../v26_2/mixin/LanServerPingerAccessor.java | 12 + .../ServerConnectionListenerAccessor.java | 13 + .../mixin/ServerConnectionListenerMixin.java | 57 ++++ .../mixin/ServerLoginPacketListenerMixin.java | 83 ++++++ .../fabric/v26_2/ConnectGameProfileMapper.kt | 52 ++++ .../share/fabric/v26_2/Minecraft262Bridge.kt | 42 +++ .../fabric/v26_2/Minecraft262LoginBridge.kt | 90 ++++++ .../v26_2/VanillaMinecraft262Transport.kt | 155 +++++++++++ .../connect-share-fabric-26.2.mixins.json | 20 ++ .../src/main/resources/fabric.mod.json | 17 ++ .../v26_2/ConnectGameProfileMapperTest.kt | 34 +++ .../fabric/v26_2/Minecraft262BridgeTest.kt | 95 +++++++ .../fabric/FabricLoginAdmissionRegistry.kt} | 5 +- 28 files changed, 1272 insertions(+), 274 deletions(-) rename share/{fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11 => common/src/main/kotlin/com/minekube/connect/share}/CapturedServerTransport.kt (98%) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ConnectionAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt create mode 100644 share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json create mode 100644 share/fabric-26.2/src/main/resources/fabric.mod.json create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt rename share/{fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt => fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt} (88%) diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 760f7e20a..43acd590e 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -1072,7 +1072,7 @@ git commit -m "feat: bridge Connect into 1.21.11 singleplayer" - Consumes: the same `MinecraftShareBridge` contract and unobfuscated 26.2 Minecraft classes. - Produces: `Minecraft262Bridge : MinecraftShareBridge` with behavior identical to Task 8. -- [ ] **Step 1: Generate 26.2 sources and verify names** +- [x] **Step 1: Generate 26.2 sources and verify names** Run: @@ -1082,7 +1082,7 @@ Run: 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`. -- [ ] **Step 2: Write parity tests** +- [x] **Step 2: Write parity tests** Run the same contract fixture against both fake adapters: @@ -1096,11 +1096,11 @@ fun bridgeContract(factory: () -> MinecraftShareBridgeHarness) { } ``` -- [ ] **Step 3: Implement the 26.2 bridge and mixins** +- [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. -- [ ] **Step 4: Build and smoke both versions** +- [x] **Step 4: Build and smoke both versions** Run: @@ -1110,7 +1110,7 @@ Run: Expected: both artifacts compile and parity tests pass. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add share/fabric-26.2 share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt diff --git a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt similarity index 98% rename from share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt rename to share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt index b879f5296..cbecf0d0d 100644 --- a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -1,4 +1,4 @@ -package com.minekube.connect.share.fabric.v1_21_11 +package com.minekube.connect.share import arrow.core.Either import arrow.core.left 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..39733eaf6 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -0,0 +1,261 @@ +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.DefaultEventLoopGroup +import io.netty.channel.EventLoopGroup +import io.netty.channel.local.LocalAddress +import io.netty.util.concurrent.DefaultThreadFactory +import java.net.InetSocketAddress +import java.net.SocketAddress + +open class VersionedMinecraftBridge( + private val transport: MinecraftVersionTransport, + private val localBinder: LocalShareChannelBinder, + private val loginAdmissionAcquire: (() -> AutoCloseable)? = null, +) : CommonPlatformInjector(), MinecraftShareBridge { + 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 admission: AutoCloseable? = null + try { + validatePublished(published).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + local = localBinder.bind(published.childInitializer) + validateLocal(local).fold( + ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, + ifRight = {}, + ) + published.addLocalListener(local) + localAdded = true + admission = loginAdmissionAcquire?.invoke() + val acquired = ActiveTransport(published, local, admission) + active = acquired + serverSocketAddress = local.address + LocalShareTarget(local.address) { + close(acquired) + } + } catch (failure: Throwable) { + var cleanup: Throwable? = failure + cleanup = releaseAfter(cleanup) { + admission?.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 class ActiveTransport( + private val published: PublishedMinecraftTransport, + private val local: LocalShareChannel, + private val admission: AutoCloseable?, + ) { + private var admissionStopped = false + private var localClosed = 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 (localClosed) { + return primary + } + localClosed = true + var failure = releaseAfter(primary) { + 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 + + fun addLocalListener(listener: LocalShareChannel) + + fun removeLocalListener(listener: LocalShareChannel) + + fun close() +} + +fun interface LocalShareChannelBinder { + fun bind(childInitializer: ChannelInitializer): LocalShareChannel +} + +interface LocalShareChannel { + val address: SocketAddress + val future: ChannelFuture? + get() = null + + fun close() +} + +class NettyLocalShareChannelBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + ): LocalShareChannel { + val eventLoop = DefaultEventLoopGroup( + 0, + DefaultThreadFactory( + "Connect Share local", + Thread.MAX_PRIORITY, + ), + ) + try { + val future = ServerBootstrap() + .channel(LocalServerChannelWrapper::class.java) + .childHandler(childInitializer) + .group(eventLoop) + .localAddress(LocalAddress.ANY) + .bind() + .syncUninterruptibly() + return NettyLocalShareChannel(future, eventLoop) + } catch (failure: Throwable) { + eventLoop.shutdownGracefully().syncUninterruptibly() + throw failure + } + } +} + +private class NettyLocalShareChannel( + override val future: ChannelFuture, + private val eventLoop: EventLoopGroup, +) : LocalShareChannel { + override val address = future.channel().localAddress() + + override fun close() { + future.closeChannel() + eventLoop.shutdownGracefully().syncUninterruptibly() + } +} + +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/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..19031b87c --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt @@ -0,0 +1,136 @@ +package com.minekube.connect.share + +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +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 bridge = VersionedMinecraftBridge(harness, FakeLocalBinder()) + + 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) + } + + @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 + 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 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, + ): LocalShareChannel = object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("failing-local") + + override fun close() { + closed = true + throw IllegalStateException("local close failed") + } + } + } + + private class FakeLocalBinder : LocalShareChannelBinder { + override fun bind( + childInitializer: ChannelInitializer, + ): LocalShareChannel = 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/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 index c274069e2..f001cd246 100644 --- 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 @@ -1,6 +1,6 @@ package com.minekube.connect.share.fabric.v1_21_11.mixin; -import com.minekube.connect.share.fabric.v1_21_11.CapturedServerTransport; +import com.minekube.connect.share.CapturedServerTransport; import net.minecraft.client.server.IntegratedServer; import net.minecraft.client.server.LanServerPinger; import org.spongepowered.asm.mixin.Mixin; 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 index 60806e753..8f2912143 100644 --- 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 @@ -1,6 +1,6 @@ package com.minekube.connect.share.fabric.v1_21_11.mixin; -import com.minekube.connect.share.fabric.v1_21_11.CapturedServerTransport; +import com.minekube.connect.share.CapturedServerTransport; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelInitializer; 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 index 569a4199f..15b6d5232 100644 --- 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 @@ -1,14 +1,7 @@ package com.minekube.connect.share.fabric.v1_21_11.mixin; import com.mojang.authlib.GameProfile; -import com.minekube.connect.api.ConnectAttributes; -import com.minekube.connect.api.player.ConnectPlayer; -import com.minekube.connect.network.netty.LocalSession; -import com.minekube.connect.share.admission.AdmissionAnswer; -import com.minekube.connect.share.fabric.v1_21_11.ConnectGameProfileMapper; -import com.minekube.connect.share.fabric.v1_21_11.Minecraft12111LoginAdmission; -import io.netty.channel.Channel; -import java.util.concurrent.CompletableFuture; +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; @@ -42,15 +35,13 @@ public abstract class ServerLoginPacketListenerMixin { private void connectShare$acceptConnectProfile( ServerboundHelloPacket hello, CallbackInfo callback) { - Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); - ConnectPlayer player = channel.attr(ConnectAttributes.CONNECT_PLAYER).get(); - if (player == null) { + if (!Minecraft12111LoginBridge.hasConnectIdentity(connection)) { return; } - GameProfile profile = - ConnectGameProfileMapper.toMinecraftOrNull(player.getGameProfile()); - if (profile == null || !hello.name().equalsIgnoreCase(profile.name())) { + GameProfile profile = Minecraft12111LoginBridge.authenticatedProfile( + connection, hello.name()); + if (profile == null) { disconnect(Component.literal("Connect identity is invalid")); callback.cancel(); return; @@ -68,9 +59,7 @@ public abstract class ServerLoginPacketListenerMixin { private void connectShare$awaitPassthroughAdmission( GameProfile profile, CallbackInfo callback) { - Channel channel = ((ConnectionAccessor) connection).getConnectShareChannel(); - LocalSession.Context context = LocalSession.context(channel).orElse(null); - if (context == null || !context.getPlayer().getAuth().isPassthrough()) { + if (!Minecraft12111LoginBridge.isPassthroughConnect(connection)) { return; } if (connectShare$admissionAllowed) { @@ -82,36 +71,11 @@ public abstract class ServerLoginPacketListenerMixin { return; } connectShare$admissionStarted = true; - CompletableFuture decision = Minecraft12111LoginAdmission.request( - profile.name(), - profile.id(), - context.getPlayer().getSessionId(), - server.usesAuthentication() && !connection.isMemoryConnection()) - .toCompletableFuture(); - channel.closeFuture().addListener(ignored -> decision.cancel(false)); - decision.whenComplete((answer, failure) -> server.execute(() -> { - if (!connection.isConnected()) { - return; - } - if (failure != null || answer != AdmissionAnswer.ALLOW) { - disconnect(connectShare$denialReason(answer)); - return; - } - connectShare$admissionAllowed = true; - })); - } - - @Unique - private Component connectShare$denialReason(@Nullable AdmissionAnswer answer) { - if (answer == AdmissionAnswer.TIMEOUT) { - return Component.literal("Host approval timed out"); - } - if (answer == AdmissionAnswer.CAPACITY) { - return Component.literal("This share is full"); - } - if (answer == AdmissionAnswer.STOPPED) { - return Component.literal("Sharing stopped"); - } - return Component.literal("Host denied this connection"); + Minecraft12111LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); } } 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 index f7399f833..6b4e7cc23 100644 --- 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 @@ -1,24 +1,30 @@ 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.minekube.connect.inject.CommonPlatformInjector -import com.minekube.connect.share.LocalShareTarget -import com.minekube.connect.share.MinecraftShareBridge -import com.minekube.connect.share.ShareOptions +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.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 -import io.netty.channel.Channel -import io.netty.channel.ChannelInitializer -import io.netty.channel.local.LocalAddress -import java.net.InetSocketAddress -import java.net.SocketAddress class Minecraft12111Bridge internal constructor( - private val transport: Minecraft12111Transport, - private val localBinder: LocalShareChannelBinder, - private val loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, -) : CommonPlatformInjector(), MinecraftShareBridge { + transport: Minecraft12111Transport, + localBinder: LocalShareChannelBinder, + loginAdmissionFactory: (() -> FabricLocalLoginAdmissionGate)? = null, +) : VersionedMinecraftBridge( + transport = transport, + localBinder = localBinder, + loginAdmissionAcquire = loginAdmissionFactory?.let { factory -> + { + FabricLoginAdmissionRegistry.install(factory()) + } + }, +) { constructor() : this( VanillaMinecraft12111Transport(), NettyLocalShareChannelBinder(), @@ -31,157 +37,12 @@ class Minecraft12111Bridge internal constructor( NettyLocalShareChannelBinder(), loginAdmissionFactory, ) - - 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 admission: AutoCloseable? = null - try { - validatePublished(published).fold( - ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, - ifRight = {}, - ) - local = localBinder.bind(published.childInitializer) - validateLocal(local).fold( - ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, - ifRight = {}, - ) - published.addLocalListener(local) - localAdded = true - admission = loginAdmissionFactory - ?.invoke() - ?.let(Minecraft12111LoginAdmission::install) - val acquired = ActiveTransport(published, local, admission) - active = acquired - serverSocketAddress = local.address - LocalShareTarget(local.address) { - close(acquired) - } - } catch (failure: Throwable) { - admission?.close() - if (localAdded) { - published.removeLocalListener(checkNotNull(local)) - } - local?.close() - published.close() - throw failure - } - } - - override fun inject(): Boolean = synchronized(lifecycleLock) { - active != null - } - - override fun isInjected(): Boolean = synchronized(lifecycleLock) { - active != null - } - - override fun shutdown() { - synchronized(lifecycleLock) { - active?.stopAdmission() - active?.closeLocal() - } - } - - 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 class ActiveTransport( - private val published: PublishedMinecraftTransport, - private val local: LocalShareChannel, - private val admission: AutoCloseable?, - ) { - private var admissionStopped = false - private var localClosed = false - - fun stopAdmission() { - if (admissionStopped) { - return - } - admissionStopped = true - admission?.close() - } - - fun closeLocal() { - if (localClosed) { - return - } - localClosed = true - published.removeLocalListener(local) - local.close() - } - - fun close() { - stopAdmission() - closeLocal() - published.close() - } - } -} - -internal fun interface Minecraft12111Transport { - fun publish(options: ShareOptions): PublishedMinecraftTransport -} - -internal interface PublishedMinecraftTransport { - val address: InetSocketAddress - val childInitializer: ChannelInitializer - - fun addLocalListener(listener: LocalShareChannel) - - fun removeLocalListener(listener: LocalShareChannel) - - fun close() -} - -internal fun interface LocalShareChannelBinder { - fun bind(childInitializer: ChannelInitializer): LocalShareChannel } -internal interface LocalShareChannel { - val address: SocketAddress - - fun close() -} - -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" - } -} +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..9f730d234 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt @@ -0,0 +1,90 @@ +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.fabric.FabricLoginAdmissionRegistry +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 + +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 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, + ).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 denialReason(answer: AdmissionAnswer?): Component = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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 index 5044a4bfb..9b39bb60a 100644 --- 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 @@ -1,19 +1,13 @@ package com.minekube.connect.share.fabric.v1_21_11 -import com.minekube.connect.network.netty.LocalServerChannelWrapper 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.bootstrap.ServerBootstrap import io.netty.channel.Channel import io.netty.channel.ChannelFuture import io.netty.channel.ChannelInitializer -import io.netty.channel.DefaultEventLoopGroup -import io.netty.channel.EventLoopGroup -import io.netty.channel.local.LocalAddress -import io.netty.util.concurrent.DefaultThreadFactory import java.net.InetSocketAddress import net.minecraft.client.Minecraft import net.minecraft.client.server.IntegratedServer @@ -122,7 +116,9 @@ private class PublishedVanillaTransport( override val childInitializer: ChannelInitializer, ) : PublishedMinecraftTransport { override fun addLocalListener(listener: LocalShareChannel) { - val future = (listener as NettyLocalShareChannel).future + 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" @@ -131,7 +127,7 @@ private class PublishedVanillaTransport( } override fun removeLocalListener(listener: LocalShareChannel) { - val future = (listener as NettyLocalShareChannel).future + val future = listener.future ?: return synchronized(channels) { channels.remove(future) } @@ -146,45 +142,6 @@ private class PublishedVanillaTransport( } } -internal class NettyLocalShareChannelBinder : LocalShareChannelBinder { - override fun bind( - childInitializer: ChannelInitializer, - ): LocalShareChannel { - val eventLoop = DefaultEventLoopGroup( - 0, - DefaultThreadFactory( - "Connect Share local", - Thread.MAX_PRIORITY, - ), - ) - try { - val future = ServerBootstrap() - .channel(LocalServerChannelWrapper::class.java) - .childHandler(childInitializer) - .group(eventLoop) - .localAddress(LocalAddress.ANY) - .bind() - .syncUninterruptibly() - return NettyLocalShareChannel(future, eventLoop) - } catch (failure: Throwable) { - eventLoop.shutdownGracefully().syncUninterruptibly() - throw failure - } - } -} - -private class NettyLocalShareChannel( - val future: ChannelFuture, - private val eventLoop: EventLoopGroup, -) : LocalShareChannel { - override val address = future.channel().localAddress() - - override fun close() { - future.closeChannel() - eventLoop.shutdownGracefully().syncUninterruptibly() - } -} - private fun ChannelFuture.closeChannel() { if (channel().isOpen) { channel().close().syncUninterruptibly() 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 index e12733288..4646ddbb1 100644 --- 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 @@ -1,5 +1,6 @@ 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 diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 5a233de29..271347edd 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -48,3 +48,10 @@ dependencies { tasks.test { useJUnitPlatform() } + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} 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/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..ba53c75b3 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/ServerLoginPacketListenerMixin.java @@ -0,0 +1,83 @@ +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)) { + 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) { + if (!Minecraft262LoginBridge.isPassthroughConnect(connection)) { + return; + } + if (connectShare$admissionAllowed) { + return; + } + + callback.cancel(); + if (connectShare$admissionStarted) { + return; + } + connectShare$admissionStarted = true; + Minecraft262LoginBridge.requestPassthroughAdmission( + connection, + server, + profile, + () -> connectShare$admissionAllowed = true, + this::disconnect); + } +} 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..b56fd12c2 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectGameProfileMapper.kt @@ -0,0 +1,52 @@ +package com.minekube.connect.share.fabric.v26_2 + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.google.common.collect.ArrayListMultimap +import com.mojang.authlib.GameProfile +import com.mojang.authlib.properties.Property +import com.mojang.authlib.properties.PropertyMap +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 = ArrayListMultimap.create() + source.properties.forEach { property -> + ensure(property.name.isNotBlank() && property.value.isNotBlank()) { + ProfileMappingFailure.InvalidProperty + } + val signature = property.signature?.takeIf(String::isNotEmpty) + val mapped = if (signature == null) { + Property(property.name, property.value) + } else { + Property(property.name, property.value, signature) + } + properties.put(property.name, mapped) + } + GameProfile( + source.uniqueId, + source.username, + PropertyMap(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/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..427ebe151 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262Bridge.kt @@ -0,0 +1,42 @@ +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.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 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..d9acbe402 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt @@ -0,0 +1,90 @@ +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.fabric.FabricLoginAdmissionRegistry +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 + +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 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, + ).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 denialReason(answer: AdmissionAnswer?): Component = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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..e7dba1943 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/VanillaMinecraft262Transport.kt @@ -0,0 +1,155 @@ +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 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, + ) + } 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, +) : 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/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..2fda986e2 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/connect-share-fabric-26.2.mixins.json @@ -0,0 +1,20 @@ +{ + "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" + ], + "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..817db758f --- /dev/null +++ b/share/fabric-26.2/src/main/resources/fabric.mod.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "connect_share", + "version": "${version}", + "name": "Connect Share", + "description": "Share a private Minecraft world through Minekube Connect.", + "environment": "*", + "mixins": [ + "connect-share-fabric-26.2.mixins.json" + ], + "depends": { + "fabricloader": ">=0.19.3", + "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/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..9035b2321 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262BridgeTest.kt @@ -0,0 +1,95 @@ +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.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 + + 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 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, + ): 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-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt similarity index 88% rename from share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt rename to share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt index 8a7ee5988..953258697 100644 --- a/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginAdmission.kt +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricLoginAdmissionRegistry.kt @@ -1,13 +1,12 @@ -package com.minekube.connect.share.fabric.v1_21_11 +package com.minekube.connect.share.fabric import com.minekube.connect.share.admission.AdmissionAnswer -import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.concurrent.atomic.AtomicReference -object Minecraft12111LoginAdmission { +object FabricLoginAdmissionRegistry { private val installed = AtomicReference() fun install(gate: FabricLocalLoginAdmissionGate): AutoCloseable { From 40921a717d3caff8c3e46a23b66f76fbeac1b2f4 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:09:50 +0200 Subject: [PATCH 106/188] feat: add Connect Share host UI --- .../v1_21_11/mixin/PauseScreenMixin.java | 55 +++ .../v1_21_11/ConnectShare12111Client.kt | 65 ++++ .../fabric/v1_21_11/EndpointIdentityScreen.kt | 171 +++++++++ .../share/fabric/v1_21_11/ShareSetupScreen.kt | 110 ++++++ .../fabric/v1_21_11/ShareStatusScreen.kt | 139 ++++++++ .../assets/connect-share/lang/de_de.json | 37 ++ .../assets/connect-share/lang/en_us.json | 37 ++ .../connect-share-fabric-1.21.11.mixins.json | 3 +- .../src/main/resources/fabric.mod.json | 13 +- .../fabric/v26_2/mixin/PauseScreenMixin.java | 55 +++ .../fabric/v26_2/ConnectShare262Client.kt | 66 ++++ .../fabric/v26_2/EndpointIdentityScreen.kt | 171 +++++++++ .../share/fabric/v26_2/ShareSetupScreen.kt | 110 ++++++ .../share/fabric/v26_2/ShareStatusScreen.kt | 139 ++++++++ .../assets/connect-share/lang/de_de.json | 37 ++ .../assets/connect-share/lang/en_us.json | 37 ++ .../connect-share-fabric-26.2.mixins.json | 3 +- .../src/main/resources/fabric.mod.json | 13 +- .../share/fabric/ConnectShareClient.kt | 75 ++++ .../share/fabric/ConnectShareRuntime.kt | 50 +++ .../share/fabric/FabricShareBootstrap.kt | 184 ++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 331 ++++++++++++++++++ .../share/fabric/ConnectShareRuntimeTest.kt | 44 +++ .../share/fabric/FabricShareBootstrapTest.kt | 20 ++ .../share/fabric/ui/ShareViewModelTest.kt | 204 +++++++++++ 25 files changed, 2163 insertions(+), 6 deletions(-) create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/PauseScreenMixin.java create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt create mode 100644 share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt create mode 100644 share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt 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..af243eb23 --- /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,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..a5ae2fbf0 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ConnectShare12111Client.kt @@ -0,0 +1,65 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +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.screens.Screen + +class ConnectShare12111Client : ClientModInitializer { + override fun onInitializeClient() { + val client = Minecraft.getInstance() + val dispatcher = client.asCoroutineDispatcher() + val scope = CoroutineScope(SupervisorJob() + dispatcher) + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share"), + minecraftVersion = SharedConstants.getCurrentVersion().name(), + worldAvailable = client.hasSingleplayerServer(), + playerCount = { + client.singleplayerServer?.playerList?.playerCount ?: 0 + }, + bridgeFactory = { admission, admissionScope -> + Minecraft12111Bridge { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission(admission), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + ) + ConnectShareClient.install(installation) + + ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + ConnectShareClient.integratedWorldChanged( + minecraft.hasSingleplayerServer(), + minecraft.singleplayerServer, + ) + } + ClientLifecycleEvents.CLIENT_STOPPING.register { + ConnectShareClient.shutdown() + } + } +} 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..e00c80162 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.addFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..475821537 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt @@ -0,0 +1,110 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.CycleButton +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 ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + + override fun init() { + val current = viewModel.state.value + minecraft?.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + + addRenderableWidget(centered(title, 32)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 52, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + current.options.gameMode, + ).withValues(ShareGameMode.entries) + .create( + width / 2 - 155, + 78, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 78, + 150, + 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( + width / 2 - 75, + 110, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft?.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, 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..78170c545 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt @@ -0,0 +1,139 @@ +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.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 18)) + + val sharing = state.shareState as? ShareState.Sharing + val address = sharing?.address + ?: Component.translatable(statusKey(state.shareState)).string + addRenderableWidget( + centered( + Component.translatable("connect_share.status.address", address), + 38, + ), + ) + val copy = addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.copy")) { + sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds(width / 2 - 50, 54, 100, 20).build(), + ) + copy.active = sharing != null + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft?.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 - 100, 80, 200, 20).build(), + ) + + val pending = state.pendingAdmissions + val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 108 + index * 38 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.source.name.lowercase() + + is AdmissionIdentity.UnverifiedOffline -> "offline" + } + val label = Component.translatable( + "connect_share.status.request", + identity.name, + identity.uuid.toString(), + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ).setMaxWidth(202), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 108 + visibleRows * 38, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 116, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft?.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } +} 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..e78f182c7 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Mit Connect teilen", + "connect_share.menu.active": "Connect Share aktiv", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.setup.max_guests": "Maximale Gäste", + "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": "Connect Share", + "connect_share.status.address": "Beitrittsadresse: %s", + "connect_share.status.copy": "Adresse kopieren", + "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 · %s · %s", + "connect_share.status.allow": "Erlauben", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Warte auf Freunde…", + "connect_share.status.stop": "Teilen beenden", + "connect_share.identity.manage": "Endpunkt-Identität…", + "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." +} 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..1227e0ea9 --- /dev/null +++ b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Share with Connect", + "connect_share.menu.active": "Connect Share active", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.setup.max_guests": "Maximum guests", + "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": "Connect Share", + "connect_share.status.address": "Join address: %s", + "connect_share.status.copy": "Copy address", + "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 · %s · %s", + "connect_share.status.allow": "Allow", + "connect_share.status.deny": "Deny", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "Waiting for friends to join…", + "connect_share.status.stop": "Stop sharing", + "connect_share.identity.manage": "Endpoint identity…", + "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." +} 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 index 12dcd00a3..1194ff0f5 100644 --- 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 @@ -12,7 +12,8 @@ "client": [ "IntegratedServerAccessor", "IntegratedServerMixin", - "LanServerPingerAccessor" + "LanServerPingerAccessor", + "PauseScreenMixin" ], "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 index 703d736bb..4217d8798 100644 --- a/share/fabric-1.21.11/src/main/resources/fabric.mod.json +++ b/share/fabric-1.21.11/src/main/resources/fabric.mod.json @@ -1,15 +1,24 @@ { "schemaVersion": 1, - "id": "connect_share", + "id": "connect-share", "version": "${version}", "name": "Connect Share", "description": "Share a private Minecraft world through Minekube Connect.", - "environment": "*", + "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-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..f873b5b30 --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/PauseScreenMixin.java @@ -0,0 +1,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..687c08036 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ConnectShare262Client.kt @@ -0,0 +1,66 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.FabricLocalLoginAdmission +import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate +import com.minekube.connect.share.fabric.FabricShareBootstrap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +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.screens.Screen + +class ConnectShare262Client : ClientModInitializer { + override fun onInitializeClient() { + val client = Minecraft.getInstance() + val scope = CoroutineScope( + SupervisorJob() + client.asCoroutineDispatcher(), + ) + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share"), + minecraftVersion = SharedConstants.getCurrentVersion().name(), + worldAvailable = client.hasSingleplayerServer(), + playerCount = { + client.singleplayerServer?.playerList?.playerCount ?: 0 + }, + bridgeFactory = { admission, admissionScope -> + Minecraft262Bridge { + FabricLocalLoginAdmissionGate( + admission = FabricLocalLoginAdmission(admission), + scope = admissionScope, + ) + } + }, + screens = { parent, active -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen( + if (active) { + ShareStatusScreen(parentScreen) + } else { + ShareSetupScreen(parentScreen) + }, + ) + } + }, + ) + ConnectShareClient.install(installation) + + ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + ConnectShareClient.integratedWorldChanged( + minecraft.hasSingleplayerServer(), + minecraft.singleplayerServer, + ) + } + ClientLifecycleEvents.CLIENT_STOPPING.register { + ConnectShareClient.shutdown() + } + } +} 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..3d684381a --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.addFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..ac369e2fa --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareSetupScreen.kt @@ -0,0 +1,110 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.CycleButton +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 ShareSetupScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.setup.title")) { + private val viewModel = ConnectShareClient.viewModel() + private var startButton: Button? = null + + override fun init() { + val current = viewModel.state.value + minecraft.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + + addRenderableWidget(centered(title, 32)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 52, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + current.options.gameMode, + ).withValues(ShareGameMode.entries) + .create( + width / 2 - 155, + 78, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 78, + 150, + 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( + width / 2 - 75, + 110, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft.gui.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, 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..38c5c28f2 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt @@ -0,0 +1,139 @@ +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.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 18)) + + val sharing = state.shareState as? ShareState.Sharing + val address = sharing?.address + ?: Component.translatable(statusKey(state.shareState)).string + addRenderableWidget( + centered( + Component.translatable("connect_share.status.address", address), + 38, + ), + ) + val copy = addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.copy")) { + sharing?.address?.let(minecraft.keyboardHandler::setClipboard) + }.bounds(width / 2 - 50, 54, 100, 20).build(), + ) + copy.active = sharing != null + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft.gui.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 - 100, 80, 200, 20).build(), + ) + + val pending = state.pendingAdmissions + val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 108 + index * 38 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.source.name.lowercase() + + is AdmissionIdentity.UnverifiedOffline -> "offline" + } + val label = Component.translatable( + "connect_share.status.request", + identity.name, + identity.uuid.toString(), + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ).setMaxWidth(202), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 108 + visibleRows * 38, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 116, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft.gui.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } +} 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..e78f182c7 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Mit Connect teilen", + "connect_share.menu.active": "Connect Share aktiv", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.setup.max_guests": "Maximale Gäste", + "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": "Connect Share", + "connect_share.status.address": "Beitrittsadresse: %s", + "connect_share.status.copy": "Adresse kopieren", + "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 · %s · %s", + "connect_share.status.allow": "Erlauben", + "connect_share.status.deny": "Ablehnen", + "connect_share.status.more": "%s weitere Anfragen", + "connect_share.status.waiting": "Warte auf Freunde…", + "connect_share.status.stop": "Teilen beenden", + "connect_share.identity.manage": "Endpunkt-Identität…", + "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." +} 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..1227e0ea9 --- /dev/null +++ b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,37 @@ +{ + "connect_share.menu.share": "Share with Connect", + "connect_share.menu.active": "Connect Share active", + "connect_share.setup.title": "Connect Share", + "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.setup.max_guests": "Maximum guests", + "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": "Connect Share", + "connect_share.status.address": "Join address: %s", + "connect_share.status.copy": "Copy address", + "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 · %s · %s", + "connect_share.status.allow": "Allow", + "connect_share.status.deny": "Deny", + "connect_share.status.more": "%s more requests", + "connect_share.status.waiting": "Waiting for friends to join…", + "connect_share.status.stop": "Stop sharing", + "connect_share.identity.manage": "Endpoint identity…", + "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." +} 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 index 2fda986e2..4087b3bcd 100644 --- 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 @@ -12,7 +12,8 @@ "client": [ "IntegratedServerAccessor", "IntegratedServerMixin", - "LanServerPingerAccessor" + "LanServerPingerAccessor", + "PauseScreenMixin" ], "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 index 817db758f..a2f169fec 100644 --- a/share/fabric-26.2/src/main/resources/fabric.mod.json +++ b/share/fabric-26.2/src/main/resources/fabric.mod.json @@ -1,15 +1,24 @@ { "schemaVersion": 1, - "id": "connect_share", + "id": "connect-share", "version": "${version}", "name": "Connect Share", "description": "Share a private Minecraft world through Minekube Connect.", - "environment": "*", + "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-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..f4ef76355 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt @@ -0,0 +1,75 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.ShareState +import com.minekube.connect.share.fabric.ui.ShareViewModel + +fun interface ConnectShareScreenFactory { + fun open(parent: Any, active: Boolean) +} + +data class ConnectShareInstallation( + val viewModel: ShareViewModel, + val runtime: ConnectShareRuntime, + val screens: ConnectShareScreenFactory, +) + +object ConnectShareClient { + @Volatile + private var installation: ConnectShareInstallation? = null + + 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 openPauseScreen(parent: Any) { + installation?.let { installed -> + installed.screens.open(parent, isShareActive()) + } + } + + @JvmStatic + fun viewModel(): ShareViewModel = + checkNotNull(installation).viewModel + + @JvmStatic + fun integratedWorldChanged( + worldAvailable: Boolean, + identity: Any?, + ) { + installation?.runtime?.integratedWorldChanged(worldAvailable, identity) + } + + @JvmStatic + fun shutdown() { + installation?.runtime?.shutdown() + } + + 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 + } +} 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..54839afba --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntime.kt @@ -0,0 +1,50 @@ +package com.minekube.connect.share.fabric + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +class ConnectShareRuntime( + private val scope: CoroutineScope, + private val stopShare: suspend () -> Unit, + private val worldAvailabilityChanged: (Boolean) -> Unit = {}, +) { + private val lock = Any() + private var currentWorldIdentity: Any? = null + + fun integratedWorldChanged( + worldAvailable: Boolean, + identity: Any? = if (worldAvailable) DEFAULT_WORLD_IDENTITY else null, + ) { + val shouldStop = synchronized(lock) { + val previous = currentWorldIdentity + currentWorldIdentity = if (worldAvailable) identity else null + previous != null && + (!worldAvailable || previous != currentWorldIdentity) + } + worldAvailabilityChanged(worldAvailable) + if (shouldStop) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + stopShare() + } + } + } + + fun shutdown() { + val shouldStop = synchronized(lock) { + (currentWorldIdentity != null).also { + currentWorldIdentity = null + } + } + worldAvailabilityChanged(false) + if (shouldStop) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + stopShare() + } + } + } + + private companion object { + val DEFAULT_WORLD_IDENTITY = Any() + } +} 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..0d8e80c7c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt @@ -0,0 +1,184 @@ +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.VersionedMinecraftBridge +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.fabric.ui.ShareViewModel +import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +import com.minekube.connect.share.identity.EndpointIdentityStore +import com.minekube.connect.util.MessageFormatter +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicReference +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient + +object FabricShareBootstrap { + fun create( + scope: CoroutineScope, + dataDirectory: Path, + minecraftVersion: String, + worldAvailable: Boolean, + playerCount: () -> Int, + bridgeFactory: + (AdmissionController, CoroutineScope) -> VersionedMinecraftBridge, + screens: ConnectShareScreenFactory, + environment: Map = System.getenv(), + logger: ConnectLogger = FabricConnectLogger(), + httpClient: OkHttpClient = OkHttpClient(), + ): ConnectShareInstallation { + val viewModelReference = AtomicReference() + val admission = AdmissionController( + scope = scope, + connectedCount = { + (playerCount() - HOST_PLAYER_COUNT).coerceAtLeast(0) + }, + maxGuests = { + viewModelReference.get()?.state?.value?.options?.maxGuests + ?: DEFAULT_MAX_GUESTS + }, + ) + val bridge = bridgeFactory(admission, scope) + val identityStore = EndpointIdentityStore( + directory = dataDirectory, + environment = environment, + endpointNames = RandomEndpointNameSource(httpClient), + tokenStore = EndpointTokenStore(), + ) + val validator = WatchEndpointCredentialValidator( + client = httpClient, + watchUrl = watchHttpUrl(environment), + timeout = 10.seconds, + ) + val ingress = FabricConnectIngress( + dataDirectory = dataDirectory, + platformInjector = bridge, + logger = logger, + platformUtils = FabricPlatformUtils( + minecraftVersion = minecraftVersion, + playerCount = playerCount, + ), + admission = admission, + scope = scope, + ) + val coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityStore::currentOrCreate, + admission = admission, + failureReporter = logger::warn, + ) + val viewModel = ShareViewModel( + scope = scope, + shareState = coordinator.state, + pendingAdmissions = admission.pending, + initialWorldAvailable = worldAvailable, + identityActions = StoredEndpointIdentityUiActions( + store = identityStore, + validator = validator, + ), + startShare = coordinator::start, + stopShare = coordinator::stop, + answerAdmission = admission::answer, + ) + viewModelReference.set(viewModel) + val runtime = ConnectShareRuntime( + scope = scope, + stopShare = { + coordinator.worldReplaced() + }, + worldAvailabilityChanged = viewModel::setWorldAvailable, + ) + return ConnectShareInstallation( + viewModel = viewModel, + runtime = runtime, + screens = screens, + ) + } + + internal fun watchHttpUrl(environment: Map) = + normalizeWebSocketScheme( + environment[WATCH_URL_ENV] ?: DEFAULT_WATCH_URL, + ).toHttpUrlOrNull() + ?: normalizeWebSocketScheme(DEFAULT_WATCH_URL).toHttpUrl() + + 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 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/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt new file mode 100644 index 000000000..c25fb9128 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt @@ -0,0 +1,331 @@ +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 java.nio.file.Path +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +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 identity: EndpointIdentitySummary? = null, + val importDraft: IdentityImportDraft = IdentityImportDraft(), + val operationInProgress: Boolean = false, + val safeMessage: String? = 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, + private val startShare: + suspend (ShareOptions) -> Either, + private val stopShare: suspend () -> Either, + private val answerAdmission: (UUID, Boolean) -> Unit, +) { + private val mutableState = MutableStateFlow( + ShareUiState( + worldAvailable = initialWorldAvailable, + shareState = shareState.value, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + ), + pendingAdmissions = pendingAdmissions.value, + ), + ) + + 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(start = CoroutineStart.UNDISPATCHED) { + 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 start() { + if (!state.value.startEnabled) return + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + startShare(state.value.options).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + } + } + + fun stop() { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + stopShare().fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + } + } + + fun allow(requestId: UUID) { + answerAdmission(requestId, true) + } + + fun deny(requestId: UUID) { + answerAdmission(requestId, false) + } + + fun setImportEndpoint(endpoint: String) { + update { + if (!importDraft.endpointEditable) { + this + } else { + copy(importDraft = importDraft.copy(endpoint = endpoint)) + } + } + } + + fun setImportToken(token: String) { + update { + if (!importDraft.tokenEditable) { + this + } else { + copy(importDraft = importDraft.copy(token = token)) + } + } + } + + fun importIdentity() { + val draft = state.value.importDraft + if (!draft.endpointEditable || !draft.tokenEditable) { + update { copy(safeMessage = MANAGED_MESSAGE) } + return + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + applyIdentityResult( + identityActions.import(draft.endpoint, draft.token), + ) + } + } + } + + fun importTokenFile(tokenFile: Path) { + val draft = state.value.importDraft + if (!draft.endpointEditable || !draft.tokenEditable) { + update { copy(safeMessage = MANAGED_MESSAGE) } + return + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + applyIdentityResult( + identityActions.importTokenFile(draft.endpoint, tokenFile), + ) + } + } + } + + fun resetIdentity() { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + runOperation { + applyIdentityResult(identityActions.reset()) + } + } + } + + private fun applyIdentityResult( + result: Either, + ) { + result.fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { identity -> + update { + copy( + identity = identity, + importDraft = IdentityImportDraft() + .withEditability(identity), + safeMessage = null, + ) + } + }, + ) + } + + private suspend fun runOperation(operation: suspend () -> Unit) { + 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 update(transform: ShareUiState.() -> ShareUiState) { + mutableState.value = mutableState.value.transform() + } + + private fun IdentityImportDraft.withEditability( + identity: EndpointIdentitySummary, + ): IdentityImportDraft = copy( + endpointEditable = !identity.endpointManagedByEnvironment, + tokenEditable = !identity.tokenManagedByEnvironment, + ) + + private companion object { + const val MANAGED_MESSAGE = + "Connect credentials are managed by the environment" + const val GENERIC_FAILURE_MESSAGE = + "Could not update Connect Share" + } +} + +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/ConnectShareRuntimeTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt new file mode 100644 index 000000000..fb947fb2a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectShareRuntimeTest.kt @@ -0,0 +1,44 @@ +package com.minekube.connect.share.fabric + +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ConnectShareRuntimeTest { + @Test + fun `leaving a world stops the active share exactly once`() = runTest { + var stopCalls = 0 + val runtime = ConnectShareRuntime( + scope = backgroundScope, + stopShare = { + stopCalls++ + }, + ) + + 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 = backgroundScope, + stopShare = { + stopCalls++ + }, + ) + + runtime.integratedWorldChanged(worldAvailable = true, identity = "one") + runtime.integratedWorldChanged(worldAvailable = true, identity = "two") + advanceUntilIdle() + + assertEquals(1, stopCalls) + } +} 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..04761ce0e --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrapTest.kt @@ -0,0 +1,20 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertEquals + +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(), + ) + } +} 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..29eabbf0b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt @@ -0,0 +1,204 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +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 java.nio.file.Path +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +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 `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 credentials are managed by the environment", + viewModel.state.value.safeMessage, + ) + } + + @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, + ) + } + + private fun TestScope.viewModel( + shareState: MutableStateFlow = + MutableStateFlow(ShareState.Idle), + pending: MutableStateFlow> = + MutableStateFlow(emptyList()), + worldAvailable: Boolean = true, + identityActions: EndpointIdentityUiActions = + FakeIdentityActions(localIdentity()), + answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, + ) = ShareViewModel( + scope = backgroundScope, + shareState = shareState, + pendingAdmissions = pending, + initialWorldAvailable = worldAvailable, + identityActions = identityActions, + startShare = { options -> + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "${options.maxGuests}.example.test", + ), + ) + }, + 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")) + } +} From 346a33f907abb6ea8c5effb4dc0609660dd08d0c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:21:59 +0200 Subject: [PATCH 107/188] build: isolate Connect Share networking runtime --- .../connect.shadow-conventions.gradle.kts | 27 ++++ .../connect/tunnel/p2p/Libp2pRuntime.java | 8 + .../tunnel/p2p/Libp2pRuntimeLoader.java | 143 +++++++++++++++++- .../p2p/Libp2pRuntimeLoaderPayloadTest.java | 31 ++++ share/fabric-1.21.11/build.gradle.kts | 70 +++++++++ .../v1_21_11/Fabric12111ArtifactTest.kt | 129 ++++++++++++++++ share/fabric-26.2/build.gradle.kts | 72 +++++++++ .../fabric/v26_2/Fabric262ArtifactTest.kt | 129 ++++++++++++++++ .../share/fabric/FabricConnectIngress.kt | 9 +- .../share/fabric/SecretRedactionTest.kt | 43 ++++++ 10 files changed, 652 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoaderPayloadTest.java create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SecretRedactionTest.kt 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 41a15884f..64d66a52d 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,20 @@ 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", + ) + } named("build") { dependsOn(shadowJar) } 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..b27c4ece7 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.", @@ -46,32 +55,81 @@ final class Libp2pRuntimeLoader { "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<>(); + codeSourceUrl().ifPresent(urls::add); + urls.add(payload.toUri().toURL()); + 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 +139,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 +214,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/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/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 7d7c82a1f..832ce7ba2 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -1,4 +1,7 @@ +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") } @@ -8,6 +11,8 @@ base { } java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 toolchain { languageVersion = JavaLanguageVersion.of(21) } @@ -31,6 +36,11 @@ repositories { } } +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + dependencies { minecraft("com.mojang:minecraft:1.21.11") mappings(loom.officialMojangMappings()) @@ -41,6 +51,12 @@ dependencies { 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") + } testImplementation(kotlin("test")) testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -48,6 +64,11 @@ dependencies { tasks.test { useJUnitPlatform() + dependsOn(tasks.remapJar) + systemProperty( + "connectShareArtifact", + tasks.remapJar.flatMap { it.archiveFile }.get().asFile.absolutePath, + ) } tasks.processResources { @@ -56,3 +77,52 @@ tasks.processResources { 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.nukkitx.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") +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")) +} +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") + } +} + +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("") +} 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..b1106c6cb --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/Fabric12111ArtifactTest.kt @@ -0,0 +1,129 @@ +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.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 `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( + 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("kotlin/") }) + + 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/") }) + } + } + + @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, + ) + assertTrue(host.classLoader === runtimeLoader) + } 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, + 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-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 271347edd..92915b131 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -1,4 +1,7 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + plugins { + id("connect.shadow-conventions") id("net.fabricmc.fabric-loom") id("org.jetbrains.kotlin.jvm") } @@ -8,6 +11,8 @@ base { } java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 toolchain { languageVersion = JavaLanguageVersion.of(25) } @@ -31,6 +36,11 @@ repositories { } } +val connectShareParentRuntime by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + dependencies { minecraft("com.mojang:minecraft:26.2") implementation("net.fabricmc:fabric-loader:${Versions.fabricLoaderVersion}") @@ -40,6 +50,12 @@ dependencies { 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") + } testImplementation(kotlin("test")) testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -55,3 +71,59 @@ tasks.processResources { 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.nukkitx.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") +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")) +} +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") + } +} + +tasks.assemble { + dependsOn(connectShareJar) +} + +tasks.test { + dependsOn(connectShareJar) + systemProperty( + "connectShareArtifact", + connectShareJar.flatMap { it.archiveFile } + .get() + .asFile + .absolutePath, + ) +} 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..2532610aa --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/Fabric262ArtifactTest.kt @@ -0,0 +1,129 @@ +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.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 Fabric262ArtifactTest { + @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( + 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("kotlin/") }) + + 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/") }) + } + } + + @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, + ) + assertTrue(host.classLoader === runtimeLoader) + } 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, + 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 { + val FORBIDDEN_TYPE_PREFIXES = listOf( + "io.libp2p.", + "io.netty.", + ) + } +} 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 index b9ec3d0e1..9fcc9b143 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -164,13 +165,19 @@ private class GuiceFabricConnectRuntimeFactory( ) } return FabricConnectRuntime { - platform.disable() + try { + platform.disable() + } finally { + Libp2pRuntime.close() + } } } catch (failure: Throwable) { try { platform.disable() } catch (cleanupFailure: Throwable) { failure.addSuppressed(cleanupFailure) + } finally { + Libp2pRuntime.close() } throw failure } 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)) + } + } +} From 3759769d73b73859490f4a7d3d03d968e4187f23 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:24:08 +0200 Subject: [PATCH 108/188] ci: verify Connect Share Fabric artifacts --- .github/workflows/pullrequest.yml | 68 ++++++++++++++++++++ README.md | 22 +++++++ docs/connect-share-testing.md | 102 ++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 docs/connect-share-testing.md diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index cb335fde3..cb47ea70a 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -59,3 +59,71 @@ jobs: with: name: Connect Velocity path: velocity/build/libs/connect-velocity.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/README.md b/README.md index ef708f29f..2445bf0ac 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,28 @@ low latency edge proxies network nearest to you. Please refer to https://connect.minekube.com for more documentation. +## Connect Share Fabric mod + +Connect Share is an in-development client-side Fabric mod for Minecraft Java +1.21.11 and 26.2. It shares a singleplayer world through the normal Connect +network without exposing Minecraft's LAN listener to the local network. + +The first slice provides: + +- a native **Share with Connect** flow in the pause menu; +- one persistent endpoint identity reused across worlds and restarts; +- import of an existing dashboard endpoint and token, including `token.json`; +- `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; +- a stable `*.play.minekube.net` address for unmodified Java clients; +- host approval before each new guest reaches the world; +- support for both authenticated and offline-mode guests; and +- isolated, self-contained Fabric artifacts for both supported game versions. + +The mod artifacts have their own build and acceptance process. They are not part +of the stable proxy/plugin release workflow. See +[docs/connect-share-testing.md](docs/connect-share-testing.md) for the manual +singleplayer acceptance pass. + ## Integrating with login / auth plugins Connect authenticates players at the edge, so login plugins that force online mode on a diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md new file mode 100644 index 000000000..176b54304 --- /dev/null +++ b/docs/connect-share-testing.md @@ -0,0 +1,102 @@ +# Connect Share singleplayer acceptance + +Connect Share is built separately for Minecraft Java 1.21.11 on Java 21 and +Minecraft Java 26.2 on Java 25. Run this pass against both artifacts before +calling the singleplayer slice 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-21-11:build +./gradlew :share:fabric-26-2:build +``` + +Use the unclassified versioned JAR in each module's `build/libs` directory. +Do not install `sources`, `dev`, `unshaded`, or `parent-shadow` artifacts. +Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. + +## Identity reuse and import + +1. Start a singleplayer world and choose **Share with Connect**. +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, including any + hostname or custom-domain configuration attached to it. +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. + +## 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. + +## Artifact inspection + +Inspect the final JARs: + +```sh +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 +``` + +Each final artifact must contain: + +- `fabric.mod.json`; +- the version-specific Connect Share mixin JSON; +- 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. + +## 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. From 7c29af693097c3503a3f48a0bfa1f1076b01c165 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:28:38 +0200 Subject: [PATCH 109/188] docs: plan Connect Share direct P2P --- .../2026-07-30-connect-share-direct-p2p.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md 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..41b5f68cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -0,0 +1,82 @@ +# 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. +- Peer identities, capabilities, invitations, and approvals are ephemeral per + share. The Connect endpoint token remains the only persistent network secret. + +## 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, ephemeral 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/QUIC, 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 multiplayer/Join Share UI is open and remove it on + close. +- Add native Minecraft Join 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. From 779ff9c85cab9c1626c0b41f2130862c5565e463 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 20:50:19 +0200 Subject: [PATCH 110/188] feat: add isolated Connect Share direct transport --- .../connect/tunnel/p2p/DirectP2pAuthMode.java | 28 + .../tunnel/p2p/DirectP2pDiscoveredShare.java | 66 ++ .../p2p/DirectP2pDiscoveryListener.java | 28 + .../tunnel/p2p/DirectP2pHostConfig.java | 74 ++ .../tunnel/p2p/DirectP2pHostHandler.java | 30 + .../connect/tunnel/p2p/DirectP2pHostInfo.java | 74 ++ .../connect/tunnel/p2p/DirectP2pNode.java | 184 ++++ .../tunnel/p2p/DirectP2pNodeRuntime.java | 966 ++++++++++++++++++ .../connect/tunnel/p2p/DirectP2pProxy.java | 52 + .../connect/tunnel/p2p/DirectP2pSession.java | 52 + .../tunnel/p2p/Libp2pRuntimeLoader.java | 9 + .../connect/tunnel/p2p/DirectP2pNodeTest.java | 219 ++++ .../connect/share/direct/ShareInviteCodec.kt | 345 +++++++ .../connect/share/direct/TransportSelector.kt | 60 ++ .../share/direct/ShareInviteCodecTest.kt | 122 +++ .../share/direct/TransportSelectorTest.kt | 91 ++ 16 files changed, 2400 insertions(+) create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pAuthMode.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveredShare.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pDiscoveryListener.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostConfig.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostHandler.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pHostInfo.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pProxy.java create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java create mode 100644 core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/direct/TransportSelector.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/direct/TransportSelectorTest.kt 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..d83017cd3 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -0,0 +1,184 @@ +/* + * 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.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 startHost; + private Method sign; + private Method publish; + private Method inspect; + private Method startDiscovery; + private Method openProxy; + private Method close; + + public DirectP2pNode() { + try { + Class runtimeClass = Class.forName( + "com.minekube.connect.tunnel.p2p.DirectP2pNodeRuntime", + true, + Libp2pRuntimeLoader.classLoader()); + java.lang.reflect.Constructor constructor = + runtimeClass.getDeclaredConstructor(); + constructor.setAccessible(true); + runtime = constructor.newInstance(); + startHost = accessible(runtimeClass.getDeclaredMethod( + "startHost", + DirectP2pHostConfig.class, + DirectP2pHostHandler.class)); + sign = accessible(runtimeClass.getDeclaredMethod("sign", byte[].class)); + publish = accessible(runtimeClass.getDeclaredMethod( + "publish", + 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 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 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..b2108ca82 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -0,0 +1,966 @@ +/* + * 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.MDnsDiscovery; +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.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +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.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; +import kotlin.Unit; + +/** + * 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 java.util.Set discoveredInvitations = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + private Host host; + private DirectP2pHostConfig hostConfig; + private DirectP2pHostHandler hostHandler; + private volatile String invitation; + private MDnsDiscovery discovery; + private DirectP2pDiscoveryListener discoveryListener; + private boolean started; + private boolean closed; + + DirectP2pNodeRuntime() { + Pair pair = KeyKt.generateKeyPair(KeyType.ED25519); + this.privateKey = pair.getFirst(); + } + + synchronized DirectP2pHostInfo startHost( + DirectP2pHostConfig config, + DirectP2pHostHandler handler) { + ensureOpen(); + if (hostConfig != null) { + throw new IllegalStateException("Connect Share direct host is already started"); + } + hostConfig = Objects.requireNonNull(config, "config"); + hostHandler = Objects.requireNonNull(handler, "handler"); + host = Libp2pTunnelTransportRuntime.createHost( + privateKey, + "/ip4/0.0.0.0/tcp/0"); + installProtocols(host); + startHostIfNeeded(); + + 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(); + if (hostConfig == null) { + throw new IllegalStateException("Connect Share direct host is not started"); + } + return privateKey.sign(Arrays.copyOf(payload, payload.length)); + } + + synchronized void publish(String invitation) { + ensureOpen(); + if (hostConfig == null || host == null) { + throw new IllegalStateException("Connect Share direct host is not started"); + } + if (this.invitation != null) { + throw new IllegalStateException("Connect Share invitation is already published"); + } + this.invitation = requireInvitation(invitation); + 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); + try { + ProxyRuntime proxy = new ProxyRuntime( + host, + address, + new DirectPreface(shareId, capability, authMode), + timeout); + proxies.add(proxy); + proxy.start(); + return new DirectP2pProxy(proxy.localAddress(), () -> { + proxy.close(); + proxies.remove(proxy); + }); + } catch (IOException e) { + throw new IllegalStateException("Could not bind the direct Minecraft proxy", e); + } + } + + synchronized void close() { + if (closed) { + return; + } + closed = true; + if (discovery != null) { + await(discovery.stop(), START_TIMEOUT_SECONDS, "stop Connect Share LAN discovery"); + 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; + } + discovery = new MDnsDiscovery( + host, + MDNS_SERVICE, + MDNS_QUERY_INTERVAL_SECONDS, + null); + discovery.addHandler(peer -> { + onMdnsPeer(peer); + return Unit.INSTANCE; + }); + await(discovery.start(), START_TIMEOUT_SECONDS, "start Connect Share LAN discovery"); + } + + 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 (discoveredInvitations.add(found.invitation())) { + 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(); + } + + 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, + 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 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 = invitation; + 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() { + Thread thread = new Thread(this::acceptAndDial, "connect-share-direct-guest"); + thread.setDaemon(true); + thread.start(); + } + + private void acceptAndDial() { + try { + client = listener.accept(); + listener.close(); + 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"); + 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/DirectP2pSession.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java new file mode 100644 index 000000000..d67e35806 --- /dev/null +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.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.util.Objects; + +public final class DirectP2pSession { + private final String peerId; + private final DirectP2pAuthMode authMode; + private final String connectionId; + + public DirectP2pSession( + String peerId, + DirectP2pAuthMode authMode, + String connectionId) { + this.peerId = Objects.requireNonNull(peerId, "peerId"); + this.authMode = Objects.requireNonNull(authMode, "authMode"); + this.connectionId = Objects.requireNonNull(connectionId, "connectionId"); + } + + public String peerId() { + return peerId; + } + + public DirectP2pAuthMode authMode() { + return authMode; + } + + public String connectionId() { + return connectionId; + } +} 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 b27c4ece7..0e237fb5c 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 @@ -50,6 +50,15 @@ 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.DirectP2pSession", "com.minekube.connect.tunnel.p2p.Libp2pEndpoint", "com.minekube.connect.tunnel.p2p.Libp2pRuntime", "com.minekube.connect.tunnel.p2p.Libp2pRuntimeLoader", 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..d48df36f7 --- /dev/null +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -0,0 +1,219 @@ +/* + * 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.List; +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; + +class DirectP2pNodeTest { + 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()); + assertFalse(session.get().peerId().isBlank()); + assertFalse(session.get().connectionId().isBlank()); + } + } + + @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 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"); + + 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-secret-payload", + discovered.invitation()); + assertFalse(discovered.toString().contains("signed-secret-payload")); + assertFalse(discovered.toString().contains(hostInfo.lanAddresses().get(0))); + } + + @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, + 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/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..928a4a3db --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/direct/ShareInviteCodec.kt @@ -0,0 +1,345 @@ +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, +) { + 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 + + 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() + return result + } + + override fun toString(): String = + "ShareInvitePayload(wireVersion=$wireVersion, shareId=$shareId, " + + "expiresAtEpochMillis=$expiresAtEpochMillis, " + + "connectAddress=$connectAddress, peerId=$peerId, " + + "internetDirectEnabled=$internetDirectEnabled, " + + "directCandidates=, capability=)" +} + +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" + } +} + +object ShareInviteCodec { + const val WIRE_VERSION = 1 + 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 FIELD_COUNT = 10 + private const val UNSIGNED_FIELD_COUNT = 9 + + fun encode(invite: SignedShareInvite): String { + val writer = CborWriter() + writer.array(FIELD_COUNT) + 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 { + array(UNSIGNED_FIELD_COUNT) + 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 == WIRE_VERSION) { + ShareInviteError.UnsupportedVersion(parsed.payload.wireVersion) + } + ensure(parsed.payload.expiresAtEpochMillis >= now.toEpochMilli()) { + ShareInviteError.Expired + } + ensure(parsed.payload.directCandidates.none(::isRelayAddress)) { + ShareInviteError.RelayCandidateForbidden + } + 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 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) + } + + 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 { + require(readLength(4) == FIELD_COUNT) + val payload = ShareInvitePayload( + wireVersion = unsigned().toInt(), + shareId = UUID.fromString(text()), + expiresAtEpochMillis = unsigned(), + connectAddress = nullableText(), + peerId = text(), + internetDirectEnabled = bool(), + directCandidates = List(readLength(4)) { text() }, + capability = text(), + ) + 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/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..c06fd5d39 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt @@ -0,0 +1,122 @@ +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 `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) + } + + private fun payload( + wireVersion: Int = ShareInviteCodec.WIRE_VERSION, + expiresAt: Long = NOW + 60_000, + 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, + ) + + 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) + } +} From ed69e71b4ae4b6a83a5aa7b8c74d9c49a4b00751 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:12:51 +0200 Subject: [PATCH 111/188] feat: complete Connect Share direct joining --- .../tunnel/p2p/DirectP2pNodeRuntime.java | 90 ++++-- .../connect/tunnel/p2p/DirectP2pRoute.java | 28 ++ .../connect/tunnel/p2p/DirectP2pSession.java | 7 + .../tunnel/p2p/Libp2pRuntimeLoader.java | 1 + .../connect/tunnel/p2p/DirectP2pNodeTest.java | 2 + .../connect/share/CapturedServerTransport.kt | 14 +- .../connect/share/DirectShareIngress.kt | 35 +++ .../connect/share/ShareCoordinator.kt | 64 +++- .../minekube/connect/share/ShareOptions.kt | 1 + .../com/minekube/connect/share/ShareState.kt | 17 +- .../share/direct/DirectSessionRegistry.kt | 65 ++++ .../connect/share/direct/ShareInviteCodec.kt | 19 ++ .../connect/share/ShareCoordinatorTest.kt | 132 +++++++++ .../share/direct/DirectSessionRegistryTest.kt | 68 +++++ .../share/direct/ShareInviteCodecTest.kt | 17 ++ .../mixin/ServerLoginPacketListenerMixin.java | 24 +- .../v1_21_11/mixin/TitleScreenMixin.java | 30 ++ .../v1_21_11/ConnectShare12111Client.kt | 13 + .../v1_21_11/Minecraft12111LoginBridge.kt | 72 +++++ .../share/fabric/v1_21_11/ShareJoinScreen.kt | 265 +++++++++++++++++ .../share/fabric/v1_21_11/ShareSetupScreen.kt | 20 ++ .../fabric/v1_21_11/ShareStatusScreen.kt | 69 ++++- .../assets/connect-share/lang/de_de.json | 19 +- .../assets/connect-share/lang/en_us.json | 19 +- .../connect-share-fabric-1.21.11.mixins.json | 3 +- .../v1_21_11/CapturedServerTransportTest.kt | 12 +- .../mixin/ServerLoginPacketListenerMixin.java | 24 +- .../fabric/v26_2/mixin/TitleScreenMixin.java | 30 ++ .../fabric/v26_2/ConnectShare262Client.kt | 13 + .../fabric/v26_2/Minecraft262LoginBridge.kt | 72 +++++ .../share/fabric/v26_2/ShareJoinScreen.kt | 260 ++++++++++++++++ .../share/fabric/v26_2/ShareSetupScreen.kt | 20 ++ .../share/fabric/v26_2/ShareStatusScreen.kt | 69 ++++- .../assets/connect-share/lang/de_de.json | 19 +- .../assets/connect-share/lang/en_us.json | 19 +- .../connect-share-fabric-26.2.mixins.json | 3 +- .../share/fabric/ConnectShareClient.kt | 85 ++++++ .../FabricDirectAuthenticationPolicy.kt | 25 ++ .../share/fabric/FabricDirectShareIngress.kt | 207 +++++++++++++ .../fabric/FabricLoginAdmissionRegistry.kt | 3 + .../fabric/FabricSessionAdmissionGate.kt | 5 +- .../share/fabric/FabricShareBootstrap.kt | 7 + .../share/fabric/FabricShareBrowser.kt | 280 ++++++++++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 6 + .../FabricDirectAuthenticationPolicyTest.kt | 28 ++ .../fabric/FabricDirectShareIngressTest.kt | 178 +++++++++++ .../FabricLocalLoginAdmissionGateTest.kt | 3 + .../share/fabric/FabricShareBrowserTest.kt | 184 ++++++++++++ .../share/fabric/GuestConnectionLeaseTest.kt | 63 ++++ 49 files changed, 2607 insertions(+), 102 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pRoute.java create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/direct/DirectSessionRegistry.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/direct/DirectSessionRegistryTest.kt create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/GuestConnectionLeaseTest.kt 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 index b2108ca82..07488f856 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -224,20 +224,29 @@ synchronized DirectP2pProxy openProxy( } ensureGuestHost(false); + ProxyRuntime proxy = null; try { - ProxyRuntime proxy = new ProxyRuntime( + proxy = new ProxyRuntime( host, address, new DirectPreface(shareId, capability, authMode), timeout); - proxies.add(proxy); proxy.start(); + proxies.add(proxy); + ProxyRuntime active = proxy; return new DirectP2pProxy(proxy.localAddress(), () -> { - proxy.close(); - proxies.remove(proxy); + active.close(); + proxies.remove(active); }); - } catch (IOException e) { - throw new IllegalStateException("Could not bind the direct Minecraft proxy", e); + } catch (Exception e) { + if (proxy != null) { + proxy.close(); + } + throw e instanceof RuntimeException + ? (RuntimeException) e + : new IllegalStateException( + "Could not bind the direct Minecraft proxy", + e); } } @@ -346,6 +355,7 @@ private void accept(Stream stream, DirectPreface preface) { 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()) { @@ -375,6 +385,27 @@ private static int listenTcpPort(Host host) { 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) { @@ -812,36 +843,37 @@ private InetSocketAddress localAddress() { } private void start() { - Thread thread = new Thread(this::acceptAndDial, "connect-share-direct-guest"); + 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 acceptAndDial() { + private void acceptAndBridge() { try { client = listener.accept(); listener.close(); - 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"); SocketBridge.install( stream, client, 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 index d67e35806..758a5962d 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pSession.java @@ -27,14 +27,17 @@ 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"); } @@ -46,6 +49,10 @@ 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/Libp2pRuntimeLoader.java b/core/src/main/java/com/minekube/connect/tunnel/p2p/Libp2pRuntimeLoader.java index 0e237fb5c..48d20db07 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 @@ -58,6 +58,7 @@ final class Libp2pRuntimeLoader { "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", 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 index d48df36f7..db16660b8 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -112,6 +112,7 @@ void twoLoopbackNodesExchangeMinecraftShapedBytes() throws Exception { 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()); } @@ -197,6 +198,7 @@ void parentBoundaryUsesOnlyJdkTypes() { DirectP2pDiscoveredShare.class, DirectP2pDiscoveryListener.class, DirectP2pProxy.class, + DirectP2pRoute.class, DirectP2pAuthMode.class); for (Class type : boundary) { 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 index cbecf0d0d..be28ccc49 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -3,6 +3,8 @@ 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 @@ -29,12 +31,20 @@ object CapturedServerTransport { 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 = initializer + ?.childInitializer = wrapped } - return initializer + return wrapped } @JvmStatic 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..1cb010ba1 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt @@ -0,0 +1,35 @@ +package com.minekube.connect.share + +import java.net.SocketAddress + +class DirectShareHandle( + val invitation: String, + val lanAvailable: Boolean, + val internetAvailable: Boolean, + val close: suspend () -> Unit, +) { + fun copy( + invitation: String = this.invitation, + lanAvailable: Boolean = this.lanAvailable, + internetAvailable: Boolean = this.internetAvailable, + close: suspend () -> Unit = this.close, + ) = DirectShareHandle( + invitation = invitation, + 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/ShareCoordinator.kt b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt index 6de783adb..79ce59a12 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -23,6 +23,7 @@ class ShareCoordinator( 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() @@ -49,17 +50,57 @@ class ShareCoordinator( acquire = { bridge.open(options) }, release = { acquired, _ -> acquired.close() }, ) - val identity = identityProvider() - val connect = install( - acquire = { ingress.start(identity, target.address) }, - release = { acquired, _ -> acquired.close() }, - ) - AcquiredShare(target, connect) + 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.address, + 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, + 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) mutableState.value = sharing @@ -123,7 +164,8 @@ class ShareCoordinator( private data class AcquiredShare( val target: LocalShareTarget, - val connect: ConnectShareHandle, + val connect: ConnectShareHandle?, + val direct: DirectShareHandle?, ) private data class ActiveShare( @@ -163,5 +205,9 @@ class ShareCoordinator( 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 index b898bc470..a11383a78 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareOptions.kt @@ -4,6 +4,7 @@ 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) { 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 index 0a623d2e8..18fdcf153 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareState.kt @@ -5,9 +5,20 @@ sealed interface ShareState { data object Starting : ShareState data class Sharing( - val endpoint: String, - val address: String, - ) : ShareState + 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 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 index 928a4a3db..539ae798d 100644 --- 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 @@ -99,6 +99,11 @@ sealed interface ShareInviteError { 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 { @@ -153,6 +158,13 @@ object ShareInviteCodec { ensure(parsed.payload.directCandidates.none(::isRelayAddress)) { ShareInviteError.RelayCandidateForbidden } + ensure( + parsed.payload.directCandidates.all { + candidatePeerId(it) == parsed.payload.peerId + }, + ) { + ShareInviteError.PeerMismatch + } parsed } } @@ -174,6 +186,13 @@ object ShareInviteCodec { 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()) 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 index afe3acae6..a2c8c9a03 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -71,6 +71,90 @@ class ShareCoordinatorTest { 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 `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() @@ -107,6 +191,37 @@ class ShareCoordinatorTest { 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() @@ -213,6 +328,11 @@ class ShareCoordinatorTest { ingressClose: suspend () -> Unit = { events += "ingress-close" }, + directStart: (suspend ( + ShareOptions, + java.net.SocketAddress, + String?, + ) -> DirectShareHandle)? = null, failureReporter: (String) -> Unit = {}, ): Fixture { val admission = AdmissionController( @@ -235,12 +355,18 @@ class ShareCoordinatorTest { 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, @@ -258,6 +384,12 @@ class ShareCoordinatorTest { 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", 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 index c06fd5d39..64829b1a9 100644 --- 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 @@ -85,6 +85,23 @@ class ShareInviteCodecTest { 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) + } + private fun payload( wireVersion: Int = ShareInviteCodec.WIRE_VERSION, expiresAt: Long = NOW + 60_000, 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 index 15b6d5232..e68d19668 100644 --- 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 @@ -59,7 +59,8 @@ public abstract class ServerLoginPacketListenerMixin { private void connectShare$awaitPassthroughAdmission( GameProfile profile, CallbackInfo callback) { - if (!Minecraft12111LoginBridge.isPassthroughConnect(connection)) { + boolean direct = Minecraft12111LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft12111LoginBridge.isPassthroughConnect(connection)) { return; } if (connectShare$admissionAllowed) { @@ -71,11 +72,20 @@ public abstract class ServerLoginPacketListenerMixin { return; } connectShare$admissionStarted = true; - Minecraft12111LoginBridge.requestPassthroughAdmission( - connection, - server, - profile, - () -> connectShare$admissionAllowed = true, - this::disconnect); + 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..05db86ab3 --- /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,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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 index a5ae2fbf0..d65b6cd5f 100644 --- 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 @@ -29,6 +29,10 @@ class ConnectShare12111Client : ClientModInitializer { playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, + worldDisplayName = { + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world" + }, bridgeFactory = { admission, admissionScope -> Minecraft12111Bridge { FabricLocalLoginAdmissionGate( @@ -49,6 +53,12 @@ class ConnectShare12111Client : ClientModInitializer { ) } }, + guestScreens = { parent -> + val parentScreen = parent as Screen + client.execute { + client.setScreen(ShareJoinScreen(parentScreen)) + } + }, ) ConnectShareClient.install(installation) @@ -57,6 +67,9 @@ class ConnectShare12111Client : ClientModInitializer { minecraft.hasSingleplayerServer(), minecraft.singleplayerServer, ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() 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 index 9f730d234..a23ec7a7b 100644 --- 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 @@ -4,7 +4,13 @@ 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.DirectOnlineAuthenticationRequired +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +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 @@ -40,6 +46,10 @@ object Minecraft12111LoginBridge { .map { it.player.auth.isPassthrough } .orElse(false) + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + @JvmStatic fun requestPassthroughAdmission( connection: Connection, @@ -60,6 +70,60 @@ object Minecraft12111LoginBridge { 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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) @@ -81,6 +145,14 @@ object Minecraft12111LoginBridge { 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 = when (answer) { AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") AdmissionAnswer.CAPACITY -> Component.literal("This share is full") 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..0f6a77f31 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt @@ -0,0 +1,265 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.EditBox +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.network.chat.Component + +class ShareJoinScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.join.title")) { + private val browser = FabricShareBrowser() + private var scope: CoroutineScope? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var joinButton: Button? = null + private var invitationValue = "" + private var selectedLanAddress: String? = null + private var safeMessage: String? = null + private var discoveredFingerprint = 0 + private var joining = false + private var transferred = false + private var selectingDiscovered = false + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft!!.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.safeMessage } + } + discoveredFingerprint = browser.discovered.value.hashCode() + + addRenderableWidget(centered(title, 16)) + addRenderableWidget( + centered( + Component.translatable("connect_share.join.description"), + 34, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 52, + 310, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { value -> + invitationValue = value + if (!selectingDiscovered) { + selectedLanAddress = null + } + refresh() + } + }, + ) + + val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) + if (discovered.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.join.scanning"), + 88, + ), + ) + } else { + discovered.forEachIndexed { index, share -> + addRenderableWidget( + Button.builder(discoveredLabel(share)) { + selectDiscovered(share) + }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) + .build(), + ) + } + } + + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(width / 2 - 155, 134) + .selected(offlineMode?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ) + .build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(width / 2 - 155, 156) + .selected(internetDirect?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + .build(), + ) + + safeMessage?.let { + addRenderableWidget( + centered(Component.literal(it), 182).setMaxWidth(310), + ) + } + joinButton = addRenderableWidget( + Button.builder(Component.translatable("connect_share.join.join")) { + join() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + override fun tick() { + super.tick() + val next = browser.discovered.value.hashCode() + if (next != discoveredFingerprint) { + invitationValue = invitationBox?.value.orEmpty() + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + minecraft?.setScreen(parent) + } + + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } + + private fun selectDiscovered(share: DiscoveredLanShare) { + selectedLanAddress = share.lanAddress + invitationValue = share.invitationUri + selectingDiscovered = true + invitationBox?.value = invitationValue + selectingDiscovered = false + safeMessage = null + refresh() + } + + private fun join() { + if (joining || invitationValue.isBlank()) return + joining = true + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = selectedLanAddress, + internetOptIn = internetDirect?.selected() == true, + authMode = if (offlineMode?.selected() == true) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + }, + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = ::connect, + ) + } + } + + private fun connect(target: GuestJoinTarget) { + val client = minecraft ?: run { + target.close() + joining = false + return + } + 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, browser) + transferred = true + } else { + browser.close() + } + val data = ServerData( + "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + ConnectScreen.startConnecting(parent, client, address, data, false, null) + } + + private fun refresh() { + joinButton?.active = !joining && invitationValue.isNotBlank() + invitationBox?.setEditable(!joining) + } + + private fun discoveredLabel(share: DiscoveredLanShare): Component = + Component.translatable( + "connect_share.join.discovered", + share.displayName, + ) + + 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 companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_SHARES = 2 + } +} 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 index 475821537..d5eb538d6 100644 --- 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 @@ -3,8 +3,10 @@ package com.minekube.connect.share.fabric.v1_21_11 import com.minekube.connect.share.ShareGameMode import com.minekube.connect.share.fabric.ConnectShareClient 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.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 @@ -67,6 +69,24 @@ class ShareSetupScreen( Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(width / 2 - 155, 138) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), 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 index 78170c545..7f054729a 100644 --- 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 @@ -18,34 +18,68 @@ class ShareStatusScreen( override fun init() { val state = viewModel.state.value fingerprint = state.hashCode() - addRenderableWidget(centered(title, 18)) + addRenderableWidget(centered(title, 14)) val sharing = state.shareState as? ShareState.Sharing - val address = sharing?.address - ?: Component.translatable(statusKey(state.shareState)).string + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } addRenderableWidget( - centered( - Component.translatable("connect_share.status.address", address), - 38, - ), + centered(summary, 32), ) - val copy = addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.copy")) { + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft!!.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 48, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 - 50, 54, 100, 20).build(), + }.bounds(width / 2 + 5, 48, 150, 20).build(), ) - copy.active = sharing != null + copyAddress.active = sharing?.address != null + + sharing?.let { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.routes", + availability(it.connectAvailable), + availability(it.lanDirectAvailable), + availability(it.internetDirectAvailable), + ), + 76, + ).setMaxWidth(310), + ) + } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft?.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 80, 200, 20).build(), + }.bounds(width / 2 - 100, 92, 200, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + val visibleRows = ((height - 166) / 38).coerceIn(1, 3) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 108 + index * 38 + val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> @@ -87,14 +121,14 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 108 + visibleRows * 38, + 120 + visibleRows * 38, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.status.waiting"), - 116, + 128, ), ) } @@ -129,6 +163,9 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + private fun availability(available: Boolean): Component = + Component.translatable(if (available) "options.on" else "options.off") + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index e78f182c7..5cc09956c 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Mit Connect teilen", "connect_share.menu.active": "Connect Share aktiv", + "connect_share.menu.join": "Connect Share beitreten", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "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.start": "Teilen starten", "connect_share.game_mode.survival": "Überleben", "connect_share.game_mode.creative": "Kreativ", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Zuschauer", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.copy": "Adresse kopieren", + "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", + "connect_share.status.copy_invitation": "Einladung kopieren", + "connect_share.status.copy_address": "Vanilla-Adresse kopieren", + "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", "connect_share.status.idle": "Nicht geteilt", "connect_share.status.starting": "Wird gestartet…", "connect_share.status.active": "Aktiv", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s weitere Anfragen", "connect_share.status.waiting": "Warte auf Freunde…", "connect_share.status.stop": "Teilen 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.identity.manage": "Endpunkt-Identität…", "connect_share.identity.title": "Connect-Endpunkt-Identität", "connect_share.identity.current": "Endpunkt: %s", 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 index 1227e0ea9..b0a048bbb 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Share with Connect", "connect_share.menu.active": "Connect Share active", + "connect_share.menu.join": "Join Connect Share", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Invite friends without opening your world to the LAN.", "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow 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.start": "Start sharing", "connect_share.game_mode.survival": "Survival", "connect_share.game_mode.creative": "Creative", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Spectator", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Join address: %s", - "connect_share.status.copy": "Copy address", + "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", + "connect_share.status.copy_invitation": "Copy invitation", + "connect_share.status.copy_address": "Copy vanilla address", + "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s more requests", "connect_share.status.waiting": "Waiting for friends to join…", "connect_share.status.stop": "Stop sharing", + "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.identity.manage": "Endpoint identity…", "connect_share.identity.title": "Connect endpoint identity", "connect_share.identity.current": "Endpoint: %s", 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 index 1194ff0f5..3481a30ad 100644 --- 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 @@ -13,7 +13,8 @@ "IntegratedServerAccessor", "IntegratedServerMixin", "LanServerPingerAccessor", - "PauseScreenMixin" + "PauseScreenMixin", + "TitleScreenMixin" ], "injectors": { "defaultRequire": 1 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 index 4646ddbb1..ee9698dda 100644 --- 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 @@ -8,22 +8,22 @@ 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 exact vanilla initializer and group only for the armed thread`() { + 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()) - assertSame( - initializer, - CapturedServerTransport.captureChildInitializer(initializer), - ) + val taggedInitializer = + CapturedServerTransport.captureChildInitializer(initializer) + assertNotSame(initializer, taggedInitializer) assertSame(group, CapturedServerTransport.captureEventLoopGroup(group)) var otherThreadArmed = true @@ -33,7 +33,7 @@ class CapturedServerTransportTest { val captured = lease.complete().getOrNull() requireNotNull(captured) - assertSame(initializer, captured.childInitializer) + assertSame(taggedInitializer, captured.childInitializer) assertSame(group, captured.eventLoopGroup) assertFalse(otherThreadArmed) assertFalse(CapturedServerTransport.isShareStartArmed()) 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 index ba53c75b3..ff9741385 100644 --- 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 @@ -61,7 +61,8 @@ private void startClientVerification(GameProfile profile) { private void connectShare$awaitPassthroughAdmission( GameProfile profile, CallbackInfo callback) { - if (!Minecraft262LoginBridge.isPassthroughConnect(connection)) { + boolean direct = Minecraft262LoginBridge.hasDirectSession(connection); + if (!direct && !Minecraft262LoginBridge.isPassthroughConnect(connection)) { return; } if (connectShare$admissionAllowed) { @@ -73,11 +74,20 @@ private void startClientVerification(GameProfile profile) { return; } connectShare$admissionStarted = true; - Minecraft262LoginBridge.requestPassthroughAdmission( - connection, - server, - profile, - () -> connectShare$admissionAllowed = true, - this::disconnect); + 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..6b7d83b8c --- /dev/null +++ b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java @@ -0,0 +1,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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 index 687c08036..c39cf33f9 100644 --- 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 @@ -30,6 +30,10 @@ class ConnectShare262Client : ClientModInitializer { playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, + worldDisplayName = { + client.singleplayerServer?.worldData?.levelName + ?: "Minecraft world" + }, bridgeFactory = { admission, admissionScope -> Minecraft262Bridge { FabricLocalLoginAdmissionGate( @@ -50,6 +54,12 @@ class ConnectShare262Client : ClientModInitializer { ) } }, + guestScreens = { parent -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen(ShareJoinScreen(parentScreen)) + } + }, ) ConnectShareClient.install(installation) @@ -58,6 +68,9 @@ class ConnectShare262Client : ClientModInitializer { minecraft.hasSingleplayerServer(), minecraft.singleplayerServer, ) + ConnectShareClient.guestConnectionChanged( + minecraft.connection != null, + ) } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() 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 index d9acbe402..f3556f0ef 100644 --- 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 @@ -4,7 +4,13 @@ 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.DirectOnlineAuthenticationRequired +import com.minekube.connect.share.fabric.FabricDirectAuthenticationPolicy import com.minekube.connect.share.fabric.FabricLoginAdmissionRegistry +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 @@ -40,6 +46,10 @@ object Minecraft262LoginBridge { .map { it.player.auth.isPassthrough } .orElse(false) + @JvmStatic + fun hasDirectSession(connection: Connection): Boolean = + directSession(connection) != null + @JvmStatic fun requestPassthroughAdmission( connection: Connection, @@ -60,6 +70,60 @@ object Minecraft262LoginBridge { 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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + return + } + val decision = FabricLoginAdmissionRegistry.request( + name = profile.name(), + uuid = profile.id(), + connectionId = session.connectionId(), + minecraftAuthenticated = minecraftAuthenticated, + ingress = session.route().toIngress(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) @@ -81,6 +145,14 @@ object Minecraft262LoginBridge { 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 = when (answer) { AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") AdmissionAnswer.CAPACITY -> Component.literal("This share is full") 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..76fbafa2e --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt @@ -0,0 +1,260 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.Checkbox +import net.minecraft.client.gui.components.EditBox +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.network.chat.Component + +class ShareJoinScreen( + private val parent: Screen, +) : Screen(Component.translatable("connect_share.join.title")) { + private val browser = FabricShareBrowser() + private var scope: CoroutineScope? = null + private var invitationBox: EditBox? = null + private var offlineMode: Checkbox? = null + private var internetDirect: Checkbox? = null + private var joinButton: Button? = null + private var invitationValue = "" + private var selectedLanAddress: String? = null + private var safeMessage: String? = null + private var discoveredFingerprint = 0 + private var joining = false + private var transferred = false + private var selectingDiscovered = false + + override fun init() { + if (scope == null) { + scope = CoroutineScope( + SupervisorJob() + minecraft.asCoroutineDispatcher(), + ) + browser.start().onLeft { safeMessage = it.safeMessage } + } + discoveredFingerprint = browser.discovered.value.hashCode() + + addRenderableWidget(centered(title, 16)) + addRenderableWidget( + centered( + Component.translatable("connect_share.join.description"), + 34, + ), + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 52, + 310, + 20, + Component.translatable("connect_share.join.invitation"), + ).apply { + setMaxLength(MAX_INVITATION_LENGTH) + setHint(Component.translatable("connect_share.join.invitation_hint")) + setValue(invitationValue) + setResponder { value -> + invitationValue = value + if (!selectingDiscovered) { + selectedLanAddress = null + } + refresh() + } + }, + ) + + val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) + if (discovered.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.join.scanning"), + 88, + ), + ) + } else { + discovered.forEachIndexed { index, share -> + addRenderableWidget( + Button.builder(discoveredLabel(share)) { + selectDiscovered(share) + }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) + .build(), + ) + } + } + + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(width / 2 - 155, 134) + .selected(offlineMode?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.offline.tooltip", + ), + ), + ) + .build(), + ) + internetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.internet"), + font, + ).pos(width / 2 - 155, 156) + .selected(internetDirect?.selected() ?: false) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + .build(), + ) + + safeMessage?.let { + addRenderableWidget( + centered(Component.literal(it), 182).setMaxWidth(310), + ) + } + joinButton = addRenderableWidget( + Button.builder(Component.translatable("connect_share.join.join")) { + join() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + override fun tick() { + super.tick() + val next = browser.discovered.value.hashCode() + if (next != discoveredFingerprint) { + invitationValue = invitationBox?.value.orEmpty() + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } + + private fun selectDiscovered(share: DiscoveredLanShare) { + selectedLanAddress = share.lanAddress + invitationValue = share.invitationUri + selectingDiscovered = true + invitationBox?.value = invitationValue + selectingDiscovered = false + safeMessage = null + refresh() + } + + private fun join() { + if (joining || invitationValue.isBlank()) return + joining = true + safeMessage = null + refresh() + scope?.launch { + browser.join( + invitationUri = invitationValue, + lanAddress = selectedLanAddress, + internetOptIn = internetDirect?.selected() == true, + authMode = if (offlineMode?.selected() == true) { + DirectP2pAuthMode.OFFLINE + } else { + DirectP2pAuthMode.ONLINE + }, + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = ::connect, + ) + } + } + + 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, browser) + transferred = true + } else { + browser.close() + } + val data = ServerData( + "Connect Share", + address.toString(), + ServerData.Type.OTHER, + ) + ConnectScreen.startConnecting(parent, minecraft, address, data, false, null) + } + + private fun refresh() { + joinButton?.active = !joining && invitationValue.isNotBlank() + invitationBox?.setEditable(!joining) + } + + private fun discoveredLabel(share: DiscoveredLanShare): Component = + Component.translatable( + "connect_share.join.discovered", + share.displayName, + ) + + 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 companion object { + const val MAX_INVITATION_LENGTH = 32_768 + const val MAX_VISIBLE_SHARES = 2 + } +} 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 index ac369e2fa..d0cb204d8 100644 --- 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 @@ -3,8 +3,10 @@ package com.minekube.connect.share.fabric.v26_2 import com.minekube.connect.share.ShareGameMode import com.minekube.connect.share.fabric.ConnectShareClient 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.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 @@ -67,6 +69,24 @@ class ShareSetupScreen( Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(width / 2 - 155, 138) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), 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 index 38c5c28f2..9e5f3ecaf 100644 --- 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 @@ -18,34 +18,68 @@ class ShareStatusScreen( override fun init() { val state = viewModel.state.value fingerprint = state.hashCode() - addRenderableWidget(centered(title, 18)) + addRenderableWidget(centered(title, 14)) val sharing = state.shareState as? ShareState.Sharing - val address = sharing?.address - ?: Component.translatable(statusKey(state.shareState)).string + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } addRenderableWidget( - centered( - Component.translatable("connect_share.status.address", address), - 38, - ), + centered(summary, 32), ) - val copy = addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.copy")) { + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 48, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 - 50, 54, 100, 20).build(), + }.bounds(width / 2 + 5, 48, 150, 20).build(), ) - copy.active = sharing != null + copyAddress.active = sharing?.address != null + + sharing?.let { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.routes", + availability(it.connectAvailable), + availability(it.lanDirectAvailable), + availability(it.internetDirectAvailable), + ), + 76, + ).setMaxWidth(310), + ) + } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 80, 200, 20).build(), + }.bounds(width / 2 - 100, 92, 200, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 154) / 38).coerceIn(1, 4) + val visibleRows = ((height - 166) / 38).coerceIn(1, 3) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 108 + index * 38 + val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> @@ -87,14 +121,14 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 108 + visibleRows * 38, + 120 + visibleRows * 38, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.status.waiting"), - 116, + 128, ), ) } @@ -129,6 +163,9 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + private fun availability(available: Boolean): Component = + Component.translatable(if (available) "options.on" else "options.off") + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index e78f182c7..5cc09956c 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Mit Connect teilen", "connect_share.menu.active": "Connect Share aktiv", + "connect_share.menu.join": "Connect Share beitreten", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", "connect_share.setup.max_guests": "Maximale Gäste", + "connect_share.setup.internet": "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.start": "Teilen starten", "connect_share.game_mode.survival": "Überleben", "connect_share.game_mode.creative": "Kreativ", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Zuschauer", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.copy": "Adresse kopieren", + "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", + "connect_share.status.copy_invitation": "Einladung kopieren", + "connect_share.status.copy_address": "Vanilla-Adresse kopieren", + "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", "connect_share.status.idle": "Nicht geteilt", "connect_share.status.starting": "Wird gestartet…", "connect_share.status.active": "Aktiv", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s weitere Anfragen", "connect_share.status.waiting": "Warte auf Freunde…", "connect_share.status.stop": "Teilen 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.identity.manage": "Endpunkt-Identität…", "connect_share.identity.title": "Connect-Endpunkt-Identität", "connect_share.identity.current": "Endpunkt: %s", 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 index 1227e0ea9..b0a048bbb 100644 --- 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 @@ -1,9 +1,12 @@ { "connect_share.menu.share": "Share with Connect", "connect_share.menu.active": "Connect Share active", + "connect_share.menu.join": "Join Connect Share", "connect_share.setup.title": "Connect Share", "connect_share.setup.description": "Invite friends without opening your world to the LAN.", "connect_share.setup.max_guests": "Maximum guests", + "connect_share.setup.internet": "Allow 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.start": "Start sharing", "connect_share.game_mode.survival": "Survival", "connect_share.game_mode.creative": "Creative", @@ -11,7 +14,10 @@ "connect_share.game_mode.spectator": "Spectator", "connect_share.status.title": "Connect Share", "connect_share.status.address": "Join address: %s", - "connect_share.status.copy": "Copy address", + "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", + "connect_share.status.copy_invitation": "Copy invitation", + "connect_share.status.copy_address": "Copy vanilla address", + "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -23,6 +29,17 @@ "connect_share.status.more": "%s more requests", "connect_share.status.waiting": "Waiting for friends to join…", "connect_share.status.stop": "Stop sharing", + "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.identity.manage": "Endpoint identity…", "connect_share.identity.title": "Connect endpoint identity", "connect_share.identity.current": "Endpoint: %s", 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 index 4087b3bcd..bd97d04d1 100644 --- 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 @@ -13,7 +13,8 @@ "IntegratedServerAccessor", "IntegratedServerMixin", "LanServerPingerAccessor", - "PauseScreenMixin" + "PauseScreenMixin", + "TitleScreenMixin" ], "injectors": { "defaultRequire": 1 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 index f4ef76355..e4e7a4ca2 100644 --- 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 @@ -7,15 +7,21 @@ fun interface ConnectShareScreenFactory { fun open(parent: Any, active: Boolean) } +fun interface ConnectShareGuestScreenFactory { + fun open(parent: Any) +} + data class ConnectShareInstallation( val viewModel: ShareViewModel, val runtime: ConnectShareRuntime, val screens: ConnectShareScreenFactory, + val guestScreens: ConnectShareGuestScreenFactory, ) object ConnectShareClient { @Volatile private var installation: ConnectShareInstallation? = null + private val guestLease = GuestConnectionLease() fun install(value: ConnectShareInstallation) { check(installation == null) { @@ -42,6 +48,23 @@ object ConnectShareClient { } } + @JvmStatic + fun openJoinScreen(parent: Any) { + installation?.guestScreens?.open(parent) + } + + fun holdGuestDirect( + target: GuestJoinTarget.Direct, + browser: FabricShareBrowser, + ) { + guestLease.hold(target, browser) + } + + @JvmStatic + fun guestConnectionChanged(connected: Boolean) { + guestLease.connectionChanged(connected) + } + @JvmStatic fun viewModel(): ShareViewModel = checkNotNull(installation).viewModel @@ -56,6 +79,7 @@ object ConnectShareClient { @JvmStatic fun shutdown() { + guestLease.close() installation?.runtime?.shutdown() } @@ -73,3 +97,64 @@ object ConnectShareClient { -> true } } + +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/FabricDirectAuthenticationPolicy.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt new file mode 100644 index 000000000..28284409c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicy.kt @@ -0,0 +1,25 @@ +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" +} + +object FabricDirectAuthenticationPolicy { + 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/FabricDirectShareIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt new file mode 100644 index 000000000..e5e7dc347 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt @@ -0,0 +1,207 @@ +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.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.security.SecureRandom +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +class FabricDirectShareIngress private constructor( + private val nodeFactory: () -> FabricDirectNode, + private val now: () -> Instant, + private val shareId: () -> UUID, + private val capability: () -> String, + private val displayName: () -> String, + private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, +) : DirectShareIngress { + constructor( + displayName: () -> String, + ) : this( + nodeFactory = { CoreFabricDirectNode(DirectP2pNode()) }, + now = Instant::now, + shareId = UUID::randomUUID, + capability = ::newCapability, + displayName = displayName, + localSocket = ::openTaggedLoopbackSocket, + ) + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + val node = nodeFactory() + try { + val id = shareId() + val secret = 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 payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = id, + expiresAtEpochMillis = now() + .plusSeconds(INVITATION_LIFETIME_SECONDS) + .toEpochMilli(), + connectAddress = connectAddress, + peerId = host.peerId(), + internetDirectEnabled = options.allowInternetDirect, + directCandidates = internetCandidates, + capability = secret, + ) + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + host.publicKey(), + ) + val invitation = ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = host.publicKey(), + signature = node.sign(unsigned), + ), + ) + node.publish(invitation) + val closed = AtomicBoolean() + return DirectShareHandle( + invitation = invitation, + lanAvailable = true, + internetAvailable = + options.allowInternetDirect && + internetCandidates.isNotEmpty(), + close = { + if (closed.compareAndSet(false, true)) { + node.close() + } + }, + ) + } catch (failure: Throwable) { + try { + node.close() + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } + throw failure + } + } + + companion object { + internal fun testing( + nodeFactory: () -> FabricDirectNode, + now: () -> Instant, + shareId: () -> UUID, + capability: () -> String, + displayName: () -> String, + localSocket: (SocketAddress, DirectP2pSession) -> Socket, + ) = FabricDirectShareIngress( + nodeFactory = nodeFactory, + now = now, + shareId = shareId, + capability = capability, + displayName = displayName, + localSocket = localSocket, + ) + + private fun newCapability(): String = ByteArray(CAPABILITY_BYTES) + .also(SecureRandom()::nextBytes) + .let(Base64.getUrlEncoder().withoutPadding()::encodeToString) + + 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 CAPABILITY_BYTES = 32 + private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L + 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) +} + +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) { + node.publish(invitation) + } + + 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 index 953258697..108ccf6c2 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -26,6 +27,7 @@ object FabricLoginAdmissionRegistry { uuid: UUID, connectionId: String, minecraftAuthenticated: Boolean, + ingress: Ingress, ): CompletionStage { val gate = installed.get() if (gate == null) { @@ -36,6 +38,7 @@ object FabricLoginAdmissionRegistry { uuid = uuid, connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, + ingress = ingress, ) } } 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 index 68f9a6b4f..264f76645 100644 --- 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 @@ -132,6 +132,7 @@ class FabricLocalLoginAdmission( uuid: UUID, connectionId: String, minecraftAuthenticated: Boolean, + ingress: Ingress = Ingress.CONNECT, ): AdmissionAnswer { val identity = if (minecraftAuthenticated) { AdmissionIdentity.Authenticated( @@ -144,7 +145,7 @@ class FabricLocalLoginAdmission( name = name, uuid = uuid, connectionId = connectionId, - ingress = Ingress.CONNECT, + ingress = ingress, ) } return admission.request(identity) @@ -163,6 +164,7 @@ class FabricLocalLoginAdmissionGate( uuid: UUID, connectionId: String, minecraftAuthenticated: Boolean, + ingress: Ingress = Ingress.CONNECT, ): CompletionStage { val future = CompletableFuture() if (stopped.get()) { @@ -179,6 +181,7 @@ class FabricLocalLoginAdmissionGate( uuid = uuid, connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, + ingress = ingress, ), ) } catch (cancellation: CancellationException) { 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 index 0d8e80c7c..43eb31ec5 100644 --- 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 @@ -26,9 +26,11 @@ object FabricShareBootstrap { minecraftVersion: String, worldAvailable: Boolean, playerCount: () -> Int, + worldDisplayName: () -> String = { "Minecraft world" }, bridgeFactory: (AdmissionController, CoroutineScope) -> VersionedMinecraftBridge, screens: ConnectShareScreenFactory, + guestScreens: ConnectShareGuestScreenFactory, environment: Map = System.getenv(), logger: ConnectLogger = FabricConnectLogger(), httpClient: OkHttpClient = OkHttpClient(), @@ -67,11 +69,15 @@ object FabricShareBootstrap { admission = admission, scope = scope, ) + val directIngress = FabricDirectShareIngress( + displayName = worldDisplayName, + ) val coordinator = ShareCoordinator( bridge = bridge, ingress = ingress, identityProvider = identityStore::currentOrCreate, admission = admission, + directIngress = directIngress, failureReporter = logger::warn, ) val viewModel = ShareViewModel( @@ -99,6 +105,7 @@ object FabricShareBootstrap { viewModel = viewModel, runtime = runtime, screens = screens, + guestScreens = guestScreens, ) } 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..de6f9f0da --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBrowser.kt @@ -0,0 +1,280 @@ +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.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.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +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 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 + } +} + +class FabricShareBrowser private constructor( + private val node: FabricGuestDirectNode, + private val now: () -> Instant, + private val ioDispatcher: CoroutineDispatcher, +) : AutoCloseable { + constructor() : this( + node = CoreFabricGuestDirectNode(DirectP2pNode()), + now = Instant::now, + ioDispatcher = Dispatchers.IO, + ) + + private val mutableDiscovered = + MutableStateFlow>(emptyList()) + private val started = AtomicBoolean() + private val closed = AtomicBoolean() + + val discovered: StateFlow> = + mutableDiscovered.asStateFlow() + + 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 routes = TransportSelector.plan( + sameLan = lanAddress != null, + hostInternetOptIn = payload.internetDirectEnabled, + guestInternetOptIn = internetOptIn, + connectAddress = payload.connectAddress, + ) + return withContext(ioDispatcher) { + for (route in routes.distinct()) { + when (route) { + ShareRoute.DIRECT_LAN -> { + val address = lanAddress ?: continue + openDirect( + route, + address, + invitation, + authMode, + LAN_TIMEOUT, + )?.let { return@withContext it.right() } + } + + ShareRoute.DIRECT_INTERNET -> { + for (address in payload.directCandidates) { + openDirect( + route, + address, + invitation, + authMode, + INTERNET_TIMEOUT, + )?.let { return@withContext it.right() } + } + } + + ShareRoute.CONNECT -> { + payload.connectAddress?.let { + return@withContext GuestJoinTarget.Connect(it).right() + } + } + } + } + GuestJoinFailure.NoRoute.left() + } + } + + 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.value = ( + mutableDiscovered.value.filterNot { + it.invitation.payload.shareId == invitation.payload.shareId + } + found + ).takeLast(MAX_DISCOVERED_SHARES) + } + + private fun openDirect( + route: ShareRoute, + address: String, + invitation: SignedShareInvite, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): GuestJoinTarget.Direct? = try { + val payload = invitation.payload + val proxy = node.openProxy( + address = address, + shareId = payload.shareId.toString(), + capability = payload.capability, + authMode = authMode, + timeout = timeout, + ) + GuestJoinTarget.Direct( + route = route, + localAddress = proxy.localAddress(), + proxy = proxy, + ) + } catch (_: RuntimeException) { + null + } + + companion object { + internal fun testing( + node: FabricGuestDirectNode, + now: () -> Instant, + ioDispatcher: CoroutineDispatcher, + ) = FabricShareBrowser(node, now, ioDispatcher) + + private val LAN_TIMEOUT = Duration.ofSeconds(3) + private val INTERNET_TIMEOUT = Duration.ofSeconds(5) + private const val MAX_DISCOVERED_SHARES = 32 + } +} + +internal interface FabricGuestDirectNode : AutoCloseable { + 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 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/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt index c25fb9128..627c8a39a 100644 --- 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 @@ -175,6 +175,12 @@ class ShareViewModel( } } + fun setAllowInternetDirect(allowed: Boolean) { + update { + copy(options = options.copy(allowInternetDirect = allowed)) + } + } + fun start() { if (!state.value.startEnabled) return scope.launch(start = CoroutineStart.UNDISPATCHED) { 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..809a8046b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectAuthenticationPolicyTest.kt @@ -0,0 +1,28 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import kotlin.test.Test +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, + ), + ) + } +} 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..8bb182467 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt @@ -0,0 +1,178 @@ +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 java.net.InetSocketAddress +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.runTest + +class FabricDirectShareIngressTest { + @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) + 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 `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) + } + + 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 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) { + if (failPublish) { + error("publish failed") + } + published = 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 index 4bb5ce895..418da7aac 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -27,12 +28,14 @@ class FabricLocalLoginAdmissionGateTest { 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() 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..288c0a862 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricShareBrowserTest.kt @@ -0,0 +1,184 @@ +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.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.security.KeyPairGenerator +import java.security.Signature +import java.time.Duration +import java.time.Instant +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 + +class FabricShareBrowserTest { + @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 `failed direct reachability falls back to Connect exactly once`() = + runTest { + val node = FakeGuestNode(failDirect = true) + val browser = browser(node) + + 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, + ) + 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) = + FabricShareBrowser.testing( + node = node, + now = { Instant.ofEpochMilli(NOW) }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + + private fun invitation(): String { + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = SHARE_ID, + expiresAtEpochMillis = NOW + 60_000, + connectAddress = "amber-fox.play.minekube.net", + peerId = PEER_ID, + internetDirectEnabled = true, + directCandidates = listOf(INTERNET_ADDRESS), + 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 class FakeGuestNode( + private val failDirect: Boolean = false, + ) : FabricGuestDirectNode { + private var listener: DirectP2pDiscoveryListener? = null + val openedAddresses = mutableListOf() + + 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: 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/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 } +} From f79dcd25aea8d54061982c201299258b627ce207 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:15:12 +0200 Subject: [PATCH 112/188] feat: show Connect Share admission routes --- .../share/admission/AdmissionIdentity.kt | 1 + .../fabric/v1_21_11/ShareStatusScreen.kt | 19 +++++++++++---- .../v1_21_11/Fabric12111ArtifactTest.kt | 6 +++++ .../share/fabric/v26_2/ShareStatusScreen.kt | 19 +++++++++++---- .../fabric/v26_2/Fabric262ArtifactTest.kt | 6 +++++ .../fabric/FabricSessionAdmissionGate.kt | 1 + .../FabricLocalLoginAdmissionGateTest.kt | 24 +++++++++++++++++++ 7 files changed, 68 insertions(+), 8 deletions(-) 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 index 6b07dc41e..6834b92dd 100644 --- 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 @@ -10,6 +10,7 @@ sealed interface AdmissionIdentity { override val name: String, override val uuid: UUID, val source: AuthSource, + val ingress: Ingress = Ingress.CONNECT, ) : AdmissionIdentity data class UnverifiedOffline( 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 index 7f054729a..15335c29a 100644 --- 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 @@ -2,6 +2,7 @@ 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.Ingress import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -82,10 +83,14 @@ class ShareStatusScreen( val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { - is AdmissionIdentity.Authenticated -> - identity.source.name.lowercase() - - is AdmissionIdentity.UnverifiedOffline -> "offline" + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( "connect_share.status.request", @@ -166,6 +171,12 @@ class ShareStatusScreen( private fun availability(available: Boolean): Component = Component.translatable(if (available) "options.on" else "options.off") + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index b1106c6cb..f44289d7b 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -42,6 +43,10 @@ class Fabric12111ArtifactTest { 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, + ) } } @@ -79,6 +84,7 @@ class Fabric12111ArtifactTest { listOf( ConnectShareClient::class.java, ShareCoordinator::class.java, + DirectP2pNode::class.java, Libp2pEndpoint::class.java, Libp2pTunnelTransport::class.java, ).forEach(::assertParentFacingTypes) 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 index 9e5f3ecaf..98c096891 100644 --- 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 @@ -2,6 +2,7 @@ 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.Ingress import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -82,10 +83,14 @@ class ShareStatusScreen( val y = 120 + index * 38 val identity = request.identity val badge = when (identity) { - is AdmissionIdentity.Authenticated -> - identity.source.name.lowercase() - - is AdmissionIdentity.UnverifiedOffline -> "offline" + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( "connect_share.status.request", @@ -166,6 +171,12 @@ class ShareStatusScreen( private fun availability(available: Boolean): Component = Component.translatable(if (available) "options.on" else "options.off") + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + private fun statusKey(state: ShareState): String = when (state) { ShareState.Idle -> "connect_share.status.idle" ShareState.Starting -> "connect_share.status.starting" 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 index 2532610aa..ffaee500f 100644 --- 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 @@ -2,6 +2,7 @@ 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.nio.file.Files @@ -42,6 +43,10 @@ class Fabric262ArtifactTest { 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, + ) } } @@ -79,6 +84,7 @@ class Fabric262ArtifactTest { listOf( ConnectShareClient::class.java, ShareCoordinator::class.java, + DirectP2pNode::class.java, Libp2pEndpoint::class.java, Libp2pTunnelTransport::class.java, ).forEach(::assertParentFacingTypes) 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 index 264f76645..ca50fbd30 100644 --- 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 @@ -139,6 +139,7 @@ class FabricLocalLoginAdmission( name = name, uuid = uuid, source = AuthSource.MOJANG, + ingress = ingress, ) } else { AdmissionIdentity.UnverifiedOffline( 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 index 418da7aac..1762dd2be 100644 --- 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 @@ -15,6 +15,30 @@ 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() From 978cc63f8200b411938ec1325463cc1d141019bf Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:15:12 +0200 Subject: [PATCH 113/188] docs: document direct share acceptance --- README.md | 16 ++++-- docs/connect-share-testing.md | 57 ++++++++++++++++++- .../2026-07-30-connect-share-direct-p2p.md | 2 +- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2445bf0ac..bb12087da 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,30 @@ Please refer to https://connect.minekube.com for more documentation. ## Connect Share Fabric mod Connect Share is an in-development client-side Fabric mod for Minecraft Java -1.21.11 and 26.2. It shares a singleplayer world through the normal Connect -network without exposing Minecraft's LAN listener to the local network. +1.21.11 and 26.2. It shares a singleplayer world through Minekube Connect or +directly between two modded clients without exposing Minecraft's listener to +the LAN or internet. -The first slice provides: +The current implementation provides: - a native **Share with Connect** flow in the pause menu; +- a native **Join Connect Share** flow on the title screen; - one persistent endpoint identity reused across worlds and restarts; - import of an existing dashboard endpoint and token, including `token.json`; - `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; - a stable `*.play.minekube.net` address for unmodified Java clients; +- signed, temporary invitations for modded clients; +- automatic same-LAN discovery and direct libp2p transport; +- optional internet-direct attempts only when host and guest both opt in; +- exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; -- support for both authenticated and offline-mode guests; and +- explicit support for authenticated and unverified offline-mode guests; and - isolated, self-contained Fabric artifacts for both supported game versions. The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See [docs/connect-share-testing.md](docs/connect-share-testing.md) for the manual -singleplayer acceptance pass. +singleplayer, direct-connect, and fallback acceptance pass. ## Integrating with login / auth plugins diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 176b54304..26f2cfd9b 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,8 +1,8 @@ -# Connect Share singleplayer acceptance +# Connect Share acceptance Connect Share is built separately for Minecraft Java 1.21.11 on Java 21 and Minecraft Java 26.2 on Java 25. Run this pass against both artifacts before -calling the singleplayer slice release-ready. +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. @@ -56,6 +56,54 @@ For each supported host version: 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 with Connect**, and leave + **Allow direct internet connections** disabled. +2. On the guest title screen, choose **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. +8. Start sharing again. Confirm the libp2p peer identity, share capability, and + invitation changed while the persistent Connect endpoint did not. + +## Invitation, internet-direct, and fallback behavior + +Internet-direct is best-effort and requires an actually reachable public +address, such as a publicly routed host or an explicitly configured network. +The mod does not open a public Minecraft listener, configure UPnP, or use a +self-hosted libp2p relay. + +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 internet-direct disabled on either peer, confirm the guest does not + attempt a direct internet route and uses Connect once. +3. Enable internet-direct on both peers. Confirm both UIs disclose that the + path reveals public IP addresses before it is attempted. +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 one bounded direct attempt is 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. + ## Listener and lifecycle safety 1. While sharing, scan the host from another LAN device. Confirm Minecraft's @@ -72,6 +120,9 @@ For each supported host version: 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 @@ -93,6 +144,8 @@ Each final artifact must contain: 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`. ## Evidence to retain 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 index 41b5f68cd..c3fc94ef6 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -42,7 +42,7 @@ classloader boundary. 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/QUIC, mDNS, +- 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 From 425377776386b09e061ee097ce7712bee81c3451 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:20:22 +0200 Subject: [PATCH 114/188] fix: package direct runtime in isolated payload --- .../tunnel/p2p/Libp2pRuntimeLoader.java | 2 +- share/fabric-1.21.11/build.gradle.kts | 11 ++++++++++- .../v1_21_11/Fabric12111ArtifactTest.kt | 19 +++++++++++++++++++ share/fabric-26.2/build.gradle.kts | 11 ++++++++++- .../fabric/v26_2/Fabric262ArtifactTest.kt | 19 +++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) 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 48d20db07..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 @@ -127,8 +127,8 @@ private static RuntimeLocation runtimeLocation() { try (InputStream input = packaged) { Path payload = extractRuntimePayload(input); Set urls = new LinkedHashSet<>(); - codeSourceUrl().ifPresent(urls::add); urls.add(payload.toUri().toURL()); + codeSourceUrl().ifPresent(urls::add); return new RuntimeLocation(urls.toArray(new URL[0]), payload); } catch (IOException e) { throw new IllegalStateException( diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 832ce7ba2..5025797dd 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -96,7 +96,16 @@ relocate("org.bstats") relocate("org.geysermc.configutils") relocate("org.yaml.snakeyaml") -val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") +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-fabric-1.21.11") 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 index f44289d7b..e965a73d2 100644 --- 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 @@ -70,7 +70,26 @@ class Fabric12111ArtifactTest { 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 } diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 92915b131..337552ca6 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -90,7 +90,16 @@ relocate("org.bstats") relocate("org.geysermc.configutils") relocate("org.yaml.snakeyaml") -val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") +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-fabric-26.2") 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 index ffaee500f..8f01ce6be 100644 --- 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 @@ -70,7 +70,26 @@ class Fabric262ArtifactTest { 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 } From 024864836483c3a8f77bab25b62a0a0b597066bb Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:23:13 +0200 Subject: [PATCH 115/188] chore: ignore Fabric run state --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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/ From 4f4ab2da735063bf242d4cfa61ac87117f40fc62 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 21:57:22 +0200 Subject: [PATCH 116/188] fix(share): isolate fastutil from Minecraft --- share/fabric-1.21.11/build.gradle.kts | 2 +- .../share/fabric/v1_21_11/Fabric12111ArtifactTest.kt | 8 ++++++++ share/fabric-26.2/build.gradle.kts | 2 +- .../connect/share/fabric/v26_2/Fabric262ArtifactTest.kt | 8 ++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 5025797dd..cf140f8e3 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -85,7 +85,7 @@ relocate("com.google.common") relocate("com.google.gson") relocate("com.google.inject") relocate("com.google.protobuf") -relocate("com.nukkitx.fastutil") +relocate("it.unimi.dsi.fastutil") relocate("io.grpc") relocate("io.leangen.geantyref") relocate("jakarta.inject") 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 index e965a73d2..064ebdf1e 100644 --- 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 @@ -34,7 +34,15 @@ class Fabric12111ArtifactTest { 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 -> diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 337552ca6..2957ca203 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -79,7 +79,7 @@ relocate("com.google.common") relocate("com.google.gson") relocate("com.google.inject") relocate("com.google.protobuf") -relocate("com.nukkitx.fastutil") +relocate("it.unimi.dsi.fastutil") relocate("io.grpc") relocate("io.leangen.geantyref") relocate("jakarta.inject") 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 index 8f01ce6be..6ec11ee65 100644 --- 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 @@ -34,7 +34,15 @@ class Fabric262ArtifactTest { 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 -> From c268c012e3612928df7da0a432100f94e56810af Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 22:32:54 +0200 Subject: [PATCH 117/188] fix(share): preserve Mojang Guava ABI --- share/fabric-1.21.11/build.gradle.kts | 8 +++++++ .../v1_21_11/MinecraftGameProfileFactory.java | 21 ++++++++++++++++++ .../v1_21_11/ConnectGameProfileMapper.kt | 12 ++++------ .../v1_21_11/Fabric12111ArtifactTest.kt | 22 +++++++++++++++++++ share/fabric-26.2/build.gradle.kts | 8 +++++++ .../v26_2/MinecraftGameProfileFactory.java | 21 ++++++++++++++++++ .../fabric/v26_2/ConnectGameProfileMapper.kt | 12 ++++------ .../fabric/v26_2/Fabric262ArtifactTest.kt | 22 +++++++++++++++++++ 8 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/MinecraftGameProfileFactory.java create mode 100644 share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/MinecraftGameProfileFactory.java diff --git a/share/fabric-1.21.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index cf140f8e3..b3bfb8feb 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -106,6 +106,9 @@ val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { ) } } +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") @@ -113,6 +116,8 @@ val connectShareShadowJar = tasks.named("shadowJar") { 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) @@ -126,6 +131,9 @@ val connectShareJar = tasks.register("connectShareJar") { from(libp2pRuntimeJar) { into("META-INF/connect") } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } } tasks.remapJar { 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/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 index f67e08a4b..0787ece34 100644 --- 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 @@ -3,10 +3,8 @@ 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.google.common.collect.ArrayListMultimap import com.mojang.authlib.GameProfile import com.mojang.authlib.properties.Property -import com.mojang.authlib.properties.PropertyMap import com.minekube.connect.api.player.GameProfile as ConnectGameProfile import net.minecraft.util.StringUtil @@ -20,23 +18,21 @@ object ConnectGameProfileMapper { ) { ProfileMappingFailure.InvalidName } - val properties = ArrayListMultimap.create() - source.properties.forEach { property -> + val properties = source.properties.map { property -> ensure(property.name.isNotBlank() && property.value.isNotBlank()) { ProfileMappingFailure.InvalidProperty } val signature = property.signature?.takeIf(String::isNotEmpty) - val mapped = if (signature == null) { + if (signature == null) { Property(property.name, property.value) } else { Property(property.name, property.value, signature) } - properties.put(property.name, mapped) } - GameProfile( + MinecraftGameProfileFactory.create( source.uniqueId, source.username, - PropertyMap(properties), + properties, ) } 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 index 064ebdf1e..4dcbfedd2 100644 --- 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 @@ -58,6 +58,28 @@ class Fabric12111ArtifactTest { } } + @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( diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 2957ca203..c687ff810 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -100,6 +100,9 @@ val libp2pRuntimeJar = tasks.named("libp2pRuntimeJar") { ) } } +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") @@ -107,6 +110,8 @@ val connectShareShadowJar = tasks.named("shadowJar") { 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) @@ -120,6 +125,9 @@ val connectShareJar = tasks.register("connectShareJar") { from(libp2pRuntimeJar) { into("META-INF/connect") } + from(sourceSets.main.get().output) { + include(minecraftGameProfileFactory) + } } tasks.assemble { 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/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 index b56fd12c2..1c23b4404 100644 --- 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 @@ -3,10 +3,8 @@ package com.minekube.connect.share.fabric.v26_2 import arrow.core.Either import arrow.core.raise.either import arrow.core.raise.ensure -import com.google.common.collect.ArrayListMultimap import com.mojang.authlib.GameProfile import com.mojang.authlib.properties.Property -import com.mojang.authlib.properties.PropertyMap import com.minekube.connect.api.player.GameProfile as ConnectGameProfile import net.minecraft.util.StringUtil @@ -20,23 +18,21 @@ object ConnectGameProfileMapper { ) { ProfileMappingFailure.InvalidName } - val properties = ArrayListMultimap.create() - source.properties.forEach { property -> + val properties = source.properties.map { property -> ensure(property.name.isNotBlank() && property.value.isNotBlank()) { ProfileMappingFailure.InvalidProperty } val signature = property.signature?.takeIf(String::isNotEmpty) - val mapped = if (signature == null) { + if (signature == null) { Property(property.name, property.value) } else { Property(property.name, property.value, signature) } - properties.put(property.name, mapped) } - GameProfile( + MinecraftGameProfileFactory.create( source.uniqueId, source.username, - PropertyMap(properties), + properties, ) } 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 index 6ec11ee65..5f0854e16 100644 --- 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 @@ -58,6 +58,28 @@ class Fabric262ArtifactTest { } } + @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 `packaged loader reads libp2p only from child payload`() { URLClassLoader( From 5343284aef40a5f125231e09dfef795c16ef4b17 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 23:09:00 +0200 Subject: [PATCH 118/188] docs(share): design pasted LAN invitation matching --- ...-connect-share-pasted-lan-invite-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md 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..c23989fd9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-connect-share-pasted-lan-invite-design.md @@ -0,0 +1,101 @@ +# Connect Share Pasted LAN Invitation Design + +**Date:** 2026-07-30 +**Status:** Approved for implementation +**Parent design:** `2026-07-30-connect-share-mod-design.md` + +## 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. From e80c854647a9183568702e68e2d5d968aad916be Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 23:11:38 +0200 Subject: [PATCH 119/188] docs(share): plan pasted LAN invitation fix --- ...6-07-30-connect-share-pasted-lan-invite.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-connect-share-pasted-lan-invite.md 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. From 00207d5926276b1987af2195a770e80a067ff44c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 Jul 2026 23:14:01 +0200 Subject: [PATCH 120/188] fix(share): prefer discovered LAN route for pasted invites --- .../share/fabric/FabricShareBrowser.kt | 17 ++- .../share/fabric/FabricShareBrowserTest.kt | 102 +++++++++++++++++- 2 files changed, 113 insertions(+), 6 deletions(-) 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 index de6f9f0da..96c3c7405 100644 --- 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 @@ -133,8 +133,10 @@ class FabricShareBrowser private constructor( ifRight = { it }, ) val payload = invitation.payload + val effectiveLanAddress = + lanAddress ?: matchingLanAddress(invitation) val routes = TransportSelector.plan( - sameLan = lanAddress != null, + sameLan = effectiveLanAddress != null, hostInternetOptIn = payload.internetDirectEnabled, guestInternetOptIn = internetOptIn, connectAddress = payload.connectAddress, @@ -143,7 +145,7 @@ class FabricShareBrowser private constructor( for (route in routes.distinct()) { when (route) { ShareRoute.DIRECT_LAN -> { - val address = lanAddress ?: continue + val address = effectiveLanAddress ?: continue openDirect( route, address, @@ -204,6 +206,17 @@ class FabricShareBrowser private constructor( ).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 openDirect( route: ShareRoute, address: String, 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 index 288c0a862..7905adc98 100644 --- 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 @@ -67,6 +67,91 @@ class FabricShareBrowserTest { 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 `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 { @@ -114,16 +199,19 @@ class FabricShareBrowserTest { ioDispatcher = StandardTestDispatcher(testScheduler), ) - private fun invitation(): String { + 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 = SHARE_ID, + shareId = shareId, expiresAtEpochMillis = NOW + 60_000, connectAddress = "amber-fox.play.minekube.net", - peerId = PEER_ID, + peerId = peerId, internetDirectEnabled = true, - directCandidates = listOf(INTERNET_ADDRESS), + directCandidates = listOf(internetAddress(peerId)), capability = CAPABILITY, ) val unsigned = ShareInviteCodec.unsignedBytes(payload, pair.public.encoded) @@ -137,6 +225,12 @@ class FabricShareBrowserTest { ) } + 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 { From 9613c60d824af36229d26ae5e55f19c65acc5a63 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 00:23:01 +0200 Subject: [PATCH 121/188] feat(share): persist direct peer identity across worlds --- .../connect/tunnel/p2p/DirectP2pNode.java | 18 ++- .../tunnel/p2p/DirectP2pNodeRuntime.java | 9 +- .../tunnel/p2p/MdnsAddressSelector.java | 150 ++++++++++++++++++ .../connect/tunnel/p2p/DirectP2pNodeTest.java | 34 ++++ .../tunnel/p2p/MdnsAddressSelectorTest.java | 81 ++++++++++ .../share/fabric/FabricDirectShareIngress.kt | 9 +- .../share/fabric/FabricShareBootstrap.kt | 1 + .../fabric/FabricDirectShareIngressTest.kt | 37 +++++ 8 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelector.java create mode 100644 core/src/test/java/com/minekube/connect/tunnel/p2p/MdnsAddressSelectorTest.java 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 index d83017cd3..838a9cc11 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -24,6 +24,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Path; import java.time.Duration; import java.util.Objects; @@ -41,15 +42,26 @@ public final class DirectP2pNode implements AutoCloseable { 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 = - runtimeClass.getDeclaredConstructor(); + java.lang.reflect.Constructor constructor = identityFile == null + ? runtimeClass.getDeclaredConstructor() + : runtimeClass.getDeclaredConstructor(Path.class); constructor.setAccessible(true); - runtime = constructor.newInstance(); + runtime = identityFile == null + ? constructor.newInstance() + : constructor.newInstance(identityFile); startHost = accessible(runtimeClass.getDeclaredMethod( "startHost", DirectP2pHostConfig.class, 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 index 07488f856..fff9cf5b9 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -57,6 +57,7 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; +import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -111,6 +112,12 @@ final class DirectP2pNodeRuntime { this.privateKey = pair.getFirst(); } + DirectP2pNodeRuntime(Path identityFile) throws IOException { + this.privateKey = EndpointPeerIdentity + .loadOrCreate(Objects.requireNonNull(identityFile, "identityFile")) + .privateKey(); + } + synchronized DirectP2pHostInfo startHost( DirectP2pHostConfig config, DirectP2pHostHandler handler) { @@ -301,7 +308,7 @@ private synchronized void startMdns() { host, MDNS_SERVICE, MDNS_QUERY_INTERVAL_SECONDS, - null); + MdnsAddressSelector.systemAddress()); discovery.addHandler(peer -> { onMdnsPeer(peer); return Unit.INSTANCE; 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/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java index db16660b8..2f0da1ff6 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -45,8 +45,12 @@ 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; @@ -142,6 +146,36 @@ void everyHostUsesAnEphemeralPeerIdentityAndSignsWithIt() throws Exception { 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 publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { host = new DirectP2pNode(); 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/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 index e5e7dc347..cbb63d97e 100644 --- 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 @@ -16,6 +16,7 @@ import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress +import java.nio.file.Path import java.security.SecureRandom import java.time.Instant import java.util.Base64 @@ -31,9 +32,14 @@ class FabricDirectShareIngress private constructor( private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, ) : DirectShareIngress { constructor( + dataDirectory: Path, displayName: () -> String, ) : this( - nodeFactory = { CoreFabricDirectNode(DirectP2pNode()) }, + nodeFactory = { + CoreFabricDirectNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ) + }, now = Instant::now, shareId = UUID::randomUUID, capability = ::newCapability, @@ -170,6 +176,7 @@ class FabricDirectShareIngress private constructor( } private const val DEFAULT_DISPLAY_NAME = "Minecraft world" + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" private const val CAPABILITY_BYTES = 32 private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L private const val LOCAL_CONNECT_TIMEOUT_MILLIS = 3_000 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 index 43eb31ec5..0b6c727b0 100644 --- 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 @@ -70,6 +70,7 @@ object FabricShareBootstrap { scope = scope, ) val directIngress = FabricDirectShareIngress( + dataDirectory = dataDirectory, displayName = worldDisplayName, ) val coordinator = ShareCoordinator( 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 index 8bb182467..2904d3820 100644 --- 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 @@ -8,7 +8,9 @@ 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 @@ -19,8 +21,12 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir class FabricDirectShareIngressTest { + @TempDir + lateinit var tempDir: Path + @Test fun `publishes a signed invitation with Connect fallback and opted-in candidates`() = runTest { @@ -123,6 +129,37 @@ class FabricDirectShareIngressTest { 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 firstPeerId = assertIs>( + ShareInviteCodec.decode(first.invitation), + ).value.payload.peerId + first.close() + Libp2pRuntime.close() + + val secondIngress = FabricDirectShareIngress( + dataDirectory = tempDir, + displayName = { "Second World" }, + ) + val second = secondIngress.start(OPTIONS, target, null) + val secondPeerId = assertIs>( + ShareInviteCodec.decode(second.invitation), + ).value.payload.peerId + + assertEquals(firstPeerId, secondPeerId) + second.close() + Libp2pRuntime.close() + } + private class FakeDirectNode( private val failPublish: Boolean = false, ) : FabricDirectNode { From a0482e790ee6d6600ba1c1d7c6494f163faaf4c7 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 00:35:28 +0200 Subject: [PATCH 122/188] feat(share): persist friend access across worlds --- .../connect/share/friend/FriendStore.kt | 337 ++++++++++++++++++ .../share/friend/ShareAccessIdentityStore.kt | 177 +++++++++ .../share/friend/SharePreferencesStore.kt | 101 ++++++ .../connect/share/friend/FriendStoreTest.kt | 141 ++++++++ .../friend/ShareAccessIdentityStoreTest.kt | 89 +++++ .../share/friend/SharePreferencesStoreTest.kt | 25 ++ .../share/fabric/ConnectShareRuntime.kt | 45 ++- .../share/fabric/FabricDirectShareIngress.kt | 30 +- .../share/fabric/FabricShareBootstrap.kt | 17 + .../connect/share/fabric/ui/ShareViewModel.kt | 66 +++- .../share/fabric/ConnectShareRuntimeTest.kt | 26 ++ .../fabric/FabricDirectShareIngressTest.kt | 15 +- .../share/fabric/ui/ShareViewModelTest.kt | 66 +++- 13 files changed, 1082 insertions(+), 53 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStore.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/ShareAccessIdentityStoreTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt 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..b38168072 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt @@ -0,0 +1,337 @@ +package com.minekube.connect.share.friend + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +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 + +data class FriendPermissions( + val notifyWhenOnline: Boolean = true, + val canSeeMyWorlds: Boolean = true, + val canJoinAutomatically: Boolean = false, +) + +data class SavedFriend( + val peerId: String, + val publicKeyBase64: String, + val shareId: UUID, + val capability: String, + val connectAddress: String?, + val displayName: String, + val permissions: FriendPermissions = FriendPermissions(), +) { + override fun toString(): String = + "SavedFriend(peerId=$peerId, publicKey=, " + + "shareId=$shareId, capability=, " + + "connectAddress=$connectAddress, displayName=$displayName, " + + "permissions=$permissions)" +} + +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" + } +} + +class FriendStore( + private val directory: Path, +) { + @Synchronized + fun all(): List = read() + + @Synchronized + fun accept( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): 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) + val existing = current.firstOrNull { + it.peerId == invite.payload.peerId + } + ensure(existing == null || existing.publicKeyBase64 == publicKey) { + FriendStoreError.IdentityConflict + } + val friend = SavedFriend( + peerId = invite.payload.peerId, + publicKeyBase64 = publicKey, + shareId = invite.payload.shareId, + capability = invite.payload.capability, + connectAddress = invite.payload.connectAddress, + displayName = existing?.displayName ?: normalizedName, + permissions = existing?.permissions ?: FriendPermissions(), + ) + write( + current.filterNot { it.peerId == friend.peerId } + friend, + ) + 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 remove(peerId: String): Boolean { + val current = read() + val remaining = current.filterNot { it.peerId == peerId } + if (remaining.size == current.size) { + return false + } + write(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 { + Files.createDirectories(directory) + if (!Files.exists(friendsFile)) { + return emptyList() + } + try { + val root = GSON.fromJson( + Files.readString(friendsFile), + JsonObject::class.java, + ) ?: throw IOException("Friends file is empty") + if (root.requiredInt("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) + } + 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") + } + return friends + } 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 parseFriend(json: JsonObject): SavedFriend { + val peerId = json.requiredString("peerId") + val publicKey = json.requiredString("publicKey") + val shareId = UUID.fromString(json.requiredString("shareId")) + val capability = json.requiredString("capability") + val connectAddress = json.optionalString("connectAddress") + val displayName = json.requiredString("displayName") + if ( + peerId.isBlank() || + publicKey.isBlank() || + !isValidCapability(capability) || + 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") + return SavedFriend( + peerId = peerId, + publicKeyBase64 = publicKey, + shareId = shareId, + capability = capability, + connectAddress = connectAddress, + displayName = displayName, + permissions = FriendPermissions( + notifyWhenOnline = permissions.requiredBoolean("notifyWhenOnline"), + canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), + canJoinAutomatically = + permissions.requiredBoolean("canJoinAutomatically"), + ), + ) + } + + private fun write(friends: List) { + require(friends.size <= MAX_FRIENDS) { + "Connect Share supports at most $MAX_FRIENDS saved friends" + } + Files.createDirectories(directory) + val entries = JsonArray() + friends.sortedBy { it.displayName.lowercase() }.forEach { friend -> + entries.add(JsonObject().apply { + addProperty("peerId", friend.peerId) + addProperty("publicKey", friend.publicKeyBase64) + addProperty("shareId", friend.shareId.toString()) + addProperty("capability", friend.capability) + friend.connectAddress?.let { + addProperty("connectAddress", it) + } + addProperty("displayName", friend.displayName) + add( + "permissions", + JsonObject().apply { + addProperty( + "notifyWhenOnline", + friend.permissions.notifyWhenOnline, + ) + addProperty( + "canSeeMyWorlds", + friend.permissions.canSeeMyWorlds, + ) + addProperty( + "canJoinAutomatically", + friend.permissions.canJoinAutomatically, + ) + }, + ) + }) + } + val root = JsonObject().apply { + addProperty("version", WIRE_VERSION) + add("friends", entries) + } + writeAtomic(GSON.toJson(root)) + } + + 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 val friendsFile: Path + get() = directory.resolve(FILE_NAME) + + companion object { + const val FILE_NAME = "friends.json" + private const val WIRE_VERSION = 1 + private const val MAX_FRIENDS = 256 + private const val MAX_DISPLAY_NAME_LENGTH = 64 + private val GSON = Gson() + + private fun isValidCapability(value: String): Boolean = + value.length in 16..512 && + value.none(Char::isWhitespace) + } +} 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..a84b315ae --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt @@ -0,0 +1,101 @@ +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 SharePreferences( + val shareWithFriends: Boolean = false, +) + +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") + if (json.requiredInt("version") != WIRE_VERSION) { + throw IOException("Share preferences version is unsupported") + } + return SharePreferences( + shareWithFriends = json.requiredBoolean("shareWithFriends"), + ) + } 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) + } + 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 WIRE_VERSION = 1 + private val GSON = Gson() + } +} 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..b5c932705 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt @@ -0,0 +1,141 @@ +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.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 `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) + 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 `removing a friend revokes the locally stored relationship`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + val removed = store.remove(PEER_ID) + + assertTrue(removed) + assertTrue(FriendStore(tempDir).all().isEmpty()) + assertFalse(store.remove(PEER_ID)) + } + + @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), + ): String { + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = SHARE_ID, + expiresAtEpochMillis = expiresAt.toEpochMilli(), + connectAddress = CONNECT_ADDRESS, + peerId = PEER_ID, + internetDirectEnabled = false, + directCandidates = emptyList(), + capability = CAPABILITY, + ) + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + KEY_PAIR.public.encoded, + ) + val signature = Signature.getInstance("Ed25519").run { + initSign(KEY_PAIR.private) + update(unsigned) + sign() + } + return ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = KEY_PAIR.public.encoded, + signature = signature, + ), + ) + } + + 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" + 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..c9f73ce5e --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/SharePreferencesStoreTest.kt @@ -0,0 +1,25 @@ +package com.minekube.connect.share.friend + +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertFalse +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) + } +} 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 index 54839afba..df8084167 100644 --- 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 @@ -3,29 +3,49 @@ package com.minekube.connect.share.fabric import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class ConnectShareRuntime( private val scope: CoroutineScope, private val stopShare: suspend () -> Unit, + private val resumeShare: suspend () -> Unit = {}, private val worldAvailabilityChanged: (Boolean) -> Unit = {}, ) { 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 shouldStop = synchronized(lock) { + val transition = synchronized(lock) { val previous = currentWorldIdentity - currentWorldIdentity = if (worldAvailable) identity else null - previous != null && - (!worldAvailable || previous != currentWorldIdentity) + val current = if (worldAvailable) identity else null + currentWorldIdentity = current + if (previous == current) { + null + } else { + WorldTransition( + stopPrevious = previous != null, + resumeCurrent = current != null, + ) + } } - worldAvailabilityChanged(worldAvailable) - if (shouldStop) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { - stopShare() + if (transition == null) { + worldAvailabilityChanged(worldAvailable) + return + } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + lifecycle.withLock { + if (transition.stopPrevious) { + stopShare() + } + worldAvailabilityChanged(worldAvailable) + if (transition.resumeCurrent) { + resumeShare() + } } } } @@ -39,11 +59,18 @@ class ConnectShareRuntime( worldAvailabilityChanged(false) if (shouldStop) { scope.launch(start = CoroutineStart.UNDISPATCHED) { - stopShare() + 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/FabricDirectShareIngress.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngress.kt index cbb63d97e..974207b5b 100644 --- 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 @@ -7,6 +7,8 @@ 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 @@ -17,17 +19,14 @@ import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress import java.nio.file.Path -import java.security.SecureRandom import java.time.Instant -import java.util.Base64 import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean class FabricDirectShareIngress private constructor( private val nodeFactory: () -> FabricDirectNode, private val now: () -> Instant, - private val shareId: () -> UUID, - private val capability: () -> String, + private val accessIdentity: () -> ShareAccessIdentity, private val displayName: () -> String, private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, ) : DirectShareIngress { @@ -41,8 +40,9 @@ class FabricDirectShareIngress private constructor( ) }, now = Instant::now, - shareId = UUID::randomUUID, - capability = ::newCapability, + accessIdentity = ShareAccessIdentityStore( + dataDirectory, + )::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, ) @@ -54,8 +54,9 @@ class FabricDirectShareIngress private constructor( ): DirectShareHandle { val node = nodeFactory() try { - val id = shareId() - val secret = capability() + val access = accessIdentity() + val id = access.shareId + val secret = access.capability val host = node.startHost( DirectP2pHostConfig( id.toString(), @@ -132,16 +133,16 @@ class FabricDirectShareIngress private constructor( ) = FabricDirectShareIngress( nodeFactory = nodeFactory, now = now, - shareId = shareId, - capability = capability, + accessIdentity = { + ShareAccessIdentity( + shareId = shareId(), + capability = capability(), + ) + }, displayName = displayName, localSocket = localSocket, ) - private fun newCapability(): String = ByteArray(CAPABILITY_BYTES) - .also(SecureRandom()::nextBytes) - .let(Base64.getUrlEncoder().withoutPadding()::encodeToString) - private fun openTaggedLoopbackSocket( target: SocketAddress, session: DirectP2pSession, @@ -177,7 +178,6 @@ class FabricDirectShareIngress private constructor( private const val DEFAULT_DISPLAY_NAME = "Minecraft world" private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" - private const val CAPABILITY_BYTES = 32 private const val INVITATION_LIFETIME_SECONDS = 24 * 60 * 60L private const val LOCAL_CONNECT_TIMEOUT_MILLIS = 3_000 } 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 index 0b6c727b0..efde07026 100644 --- 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 @@ -7,6 +7,8 @@ import com.minekube.connect.share.VersionedMinecraftBridge import com.minekube.connect.share.admission.AdmissionController import com.minekube.connect.share.fabric.ui.ShareViewModel import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +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.util.MessageFormatter import java.nio.file.Path @@ -53,6 +55,13 @@ object FabricShareBootstrap { 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 validator = WatchEndpointCredentialValidator( client = httpClient, watchUrl = watchHttpUrl(environment), @@ -86,6 +95,13 @@ object FabricShareBootstrap { shareState = coordinator.state, pendingAdmissions = admission.pending, initialWorldAvailable = worldAvailable, + initialShareWithFriendsEnabled = + initialPreferences.shareWithFriends, + persistShareWithFriendsEnabled = { enabled -> + preferencesStore.save( + SharePreferences(shareWithFriends = enabled), + ) + }, identityActions = StoredEndpointIdentityUiActions( store = identityStore, validator = validator, @@ -100,6 +116,7 @@ object FabricShareBootstrap { stopShare = { coordinator.worldReplaced() }, + resumeShare = viewModel::resumeIfEnabled, worldAvailabilityChanged = viewModel::setWorldAvailable, ) return ConnectShareInstallation( 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 index 627c8a39a..1bf9547da 100644 --- 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 @@ -49,6 +49,7 @@ data class ShareUiState( val shareState: ShareState, val options: ShareOptions, val pendingAdmissions: List, + val shareWithFriendsEnabled: Boolean = false, val identity: EndpointIdentitySummary? = null, val importDraft: IdentityImportDraft = IdentityImportDraft(), val operationInProgress: Boolean = false, @@ -107,6 +108,8 @@ class ShareViewModel( pendingAdmissions: StateFlow>, initialWorldAvailable: Boolean, private val identityActions: EndpointIdentityUiActions, + initialShareWithFriendsEnabled: Boolean = false, + private val persistShareWithFriendsEnabled: (Boolean) -> Unit = {}, private val startShare: suspend (ShareOptions) -> Either, private val stopShare: suspend () -> Either, @@ -121,6 +124,7 @@ class ShareViewModel( allowCheats = false, ), pendingAdmissions = pendingAdmissions.value, + shareWithFriendsEnabled = initialShareWithFriendsEnabled, ), ) @@ -185,14 +189,8 @@ class ShareViewModel( if (!state.value.startEnabled) return scope.launch(start = CoroutineStart.UNDISPATCHED) { runOperation { - startShare(state.value.options).fold( - ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } - }, - ifRight = { - update { copy(safeMessage = null) } - }, - ) + setShareWithFriendsEnabled(true) + startCurrentWorld() } } } @@ -200,18 +198,27 @@ class ShareViewModel( fun stop() { scope.launch(start = CoroutineStart.UNDISPATCHED) { runOperation { - stopShare().fold( - ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } - }, - ifRight = { - update { copy(safeMessage = null) } - }, - ) + try { + setShareWithFriendsEnabled(false) + } finally { + stopCurrentWorld() + } } } } + suspend fun resumeIfEnabled() { + if ( + !state.value.shareWithFriendsEnabled || + !state.value.startEnabled + ) { + return + } + runOperation { + startCurrentWorld() + } + } + fun allow(requestId: UUID) { answerAdmission(requestId, true) } @@ -298,6 +305,33 @@ class ShareViewModel( ) } + 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.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + + private suspend fun stopCurrentWorld() { + stopShare().fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + update { copy(safeMessage = null) } + }, + ) + } + private suspend fun runOperation(operation: suspend () -> Unit) { update { copy(operationInProgress = true) } try { 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 index fb947fb2a..1e7eb4d6c 100644 --- 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 @@ -41,4 +41,30 @@ class ConnectShareRuntimeTest { assertEquals(1, stopCalls) } + + @Test + fun `enabled sharing resumes when the host enters or switches worlds`() = runTest { + val lifecycle = mutableListOf() + val runtime = ConnectShareRuntime( + scope = backgroundScope, + stopShare = { + lifecycle += "stop" + }, + resumeShare = { + lifecycle += "resume" + }, + ) + + 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/FabricDirectShareIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt index 2904d3820..32c4c68ea 100644 --- 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 @@ -140,9 +140,9 @@ class FabricDirectShareIngressTest { displayName = { "First World" }, ) val first = firstIngress.start(OPTIONS, target, null) - val firstPeerId = assertIs>( + val firstInvite = assertIs>( ShareInviteCodec.decode(first.invitation), - ).value.payload.peerId + ).value first.close() Libp2pRuntime.close() @@ -151,11 +151,16 @@ class FabricDirectShareIngressTest { displayName = { "Second World" }, ) val second = secondIngress.start(OPTIONS, target, null) - val secondPeerId = assertIs>( + val secondInvite = assertIs>( ShareInviteCodec.decode(second.invitation), - ).value.payload.peerId + ).value - assertEquals(firstPeerId, secondPeerId) + 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() } 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 index 29eabbf0b..39a747674 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -130,6 +131,48 @@ class ShareViewModelTest { ) } + @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 `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) + } + private fun TestScope.viewModel( shareState: MutableStateFlow = MutableStateFlow(ShareState.Idle), @@ -139,20 +182,27 @@ class ShareViewModelTest { identityActions: EndpointIdentityUiActions = FakeIdentityActions(localIdentity()), answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, + initialShareWithFriends: Boolean = false, + persistShareWithFriends: (Boolean) -> Unit = {}, + startShare: + suspend (ShareOptions) -> Either = + { options -> + Either.Right( + ShareState.Sharing( + endpoint = "share", + address = "${options.maxGuests}.example.test", + ), + ) + }, ) = ShareViewModel( scope = backgroundScope, shareState = shareState, pendingAdmissions = pending, initialWorldAvailable = worldAvailable, identityActions = identityActions, - startShare = { options -> - Either.Right( - ShareState.Sharing( - endpoint = "share", - address = "${options.maxGuests}.example.test", - ), - ) - }, + initialShareWithFriendsEnabled = initialShareWithFriends, + persistShareWithFriendsEnabled = persistShareWithFriends, + startShare = startShare, stopShare = { Either.Right(Unit) }, answerAdmission = answerAdmission, ) From 5e5fe55f545aa883ed997b26e2ace06f0a413e9e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 01:40:49 +0200 Subject: [PATCH 123/188] feat(share): add persistent friend sharing --- .../connect/tunnel/p2p/DirectP2pNode.java | 12 + .../tunnel/p2p/DirectP2pNodeRuntime.java | 13 +- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 30 + .../share/admission/AdmissionController.kt | 4 + .../share/admission/AdmissionIdentity.kt | 3 + .../share/admission/NewAdmissionTracker.kt | 17 + .../connect/share/friend/FriendStore.kt | 18 +- .../admission/AdmissionControllerTest.kt | 28 + .../admission/NewAdmissionTrackerTest.kt | 33 ++ .../connect/share/friend/FriendStoreTest.kt | 18 + .../v1_21_11/ConnectShare12111Client.kt | 89 ++- .../fabric/v1_21_11/FriendCardNetworking.kt | 92 +++ .../fabric/v1_21_11/FriendCardPayload.kt | 55 ++ .../v1_21_11/Minecraft12111LoginBridge.kt | 1 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 550 ++++++++++++++---- .../share/fabric/v1_21_11/ShareSetupScreen.kt | 32 +- .../fabric/v1_21_11/ShareStatusScreen.kt | 57 +- .../assets/connect-share/lang/de_de.json | 64 +- .../assets/connect-share/lang/en_us.json | 64 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 24 + .../fabric/v1_21_11/FriendCardPayloadTest.kt | 41 ++ .../fabric/v26_2/ConnectShare262Client.kt | 89 ++- .../fabric/v26_2/FriendCardNetworking.kt | 92 +++ .../share/fabric/v26_2/FriendCardPayload.kt | 55 ++ .../fabric/v26_2/Minecraft262LoginBridge.kt | 1 + .../share/fabric/v26_2/ShareJoinScreen.kt | 542 +++++++++++++---- .../share/fabric/v26_2/ShareSetupScreen.kt | 26 +- .../share/fabric/v26_2/ShareStatusScreen.kt | 47 +- .../assets/connect-share/lang/de_de.json | 64 +- .../assets/connect-share/lang/en_us.json | 64 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 24 + .../fabric/v26_2/FriendCardPayloadTest.kt | 41 ++ .../share/fabric/ApprovedJoinTracker.kt | 86 +++ .../share/fabric/ConnectShareClient.kt | 17 + .../share/fabric/FabricConnectIngress.kt | 12 +- .../fabric/FabricLoginAdmissionRegistry.kt | 2 + .../fabric/FabricSessionAdmissionGate.kt | 17 +- .../share/fabric/FabricShareBootstrap.kt | 35 +- .../share/fabric/FabricShareBrowser.kt | 71 ++- .../share/fabric/FriendCardExchangeConsent.kt | 34 ++ .../connect/share/fabric/FriendCardIssuer.kt | 93 +++ .../share/fabric/FriendPresenceMonitor.kt | 88 +++ .../share/fabric/MinecraftStatusProbe.kt | 205 +++++++ .../share/fabric/ui/FriendsViewModel.kt | 163 ++++++ .../share/fabric/ApprovedJoinTrackerTest.kt | 71 +++ .../fabric/FabricSessionAdmissionGateTest.kt | 30 +- .../share/fabric/FabricShareBrowserTest.kt | 91 +++ .../fabric/FriendCardExchangeConsentTest.kt | 62 ++ .../share/fabric/FriendCardIssuerTest.kt | 115 ++++ .../share/fabric/FriendPresenceMonitorTest.kt | 92 +++ .../share/fabric/MinecraftStatusProbeTest.kt | 114 ++++ .../share/fabric/ui/FriendsViewModelTest.kt | 250 ++++++++ 52 files changed, 3584 insertions(+), 354 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/admission/NewAdmissionTracker.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/admission/NewAdmissionTrackerTest.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt create mode 100644 share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt create mode 100644 share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbeTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt 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 index 838a9cc11..f13ed7146 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -33,6 +33,8 @@ */ public final class DirectP2pNode implements AutoCloseable { private Object runtime; + private Method peerId; + private Method publicKey; private Method startHost; private Method sign; private Method publish; @@ -62,6 +64,8 @@ private void initialize(Path identityFile) { 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, @@ -92,6 +96,14 @@ private void initialize(Path identityFile) { } } + 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) { 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 index fff9cf5b9..c17406317 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -118,6 +118,16 @@ final class DirectP2pNodeRuntime { .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) { @@ -152,9 +162,6 @@ synchronized DirectP2pHostInfo startHost( synchronized byte[] sign(byte[] payload) { ensureOpen(); - if (hostConfig == null) { - throw new IllegalStateException("Connect Share direct host is not started"); - } return privateKey.sign(Arrays.copyOf(payload, payload.length)); } 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 index 2f0da1ff6..9a73a21d4 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -176,6 +176,36 @@ void persistentIdentitySurvivesNodeRestarts() { 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(); 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 index c4581b50b..42400cfb7 100644 --- 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 @@ -19,6 +19,7 @@ class AdmissionController( private val maxPending: Int = 16, private val connectedCount: () -> Int, private val maxGuests: () -> Int, + private val autoApprove: (AdmissionIdentity) -> Boolean = { false }, ) { private val lock = Any() private val requests = linkedMapOf() @@ -41,6 +42,9 @@ class AdmissionController( if (connectedCount() >= maxGuests()) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } + if (autoApprove(identity)) { + return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) + } if ( identity is AdmissionIdentity.Authenticated && identity.uuid in authenticatedApprovals 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 index 6834b92dd..2d270b391 100644 --- 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 @@ -5,12 +5,14 @@ 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( @@ -18,6 +20,7 @@ sealed interface AdmissionIdentity { override val uuid: UUID, val connectionId: String, val ingress: Ingress, + override val directPeerId: String? = null, ) : AdmissionIdentity } 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/friend/FriendStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendStore.kt index b38168072..bffa267aa 100644 --- 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 @@ -39,13 +39,14 @@ data class SavedFriend( val capability: String, val connectAddress: String?, val displayName: String, + val minecraftUuid: UUID? = null, val permissions: FriendPermissions = FriendPermissions(), ) { override fun toString(): String = "SavedFriend(peerId=$peerId, publicKey=, " + "shareId=$shareId, capability=, " + "connectAddress=$connectAddress, displayName=$displayName, " + - "permissions=$permissions)" + "minecraftUuid=$minecraftUuid, permissions=$permissions)" } sealed interface FriendStoreError { @@ -106,6 +107,7 @@ class FriendStore( capability = invite.payload.capability, connectAddress = invite.payload.connectAddress, displayName = existing?.displayName ?: normalizedName, + minecraftUuid = existing?.minecraftUuid, permissions = existing?.permissions ?: FriendPermissions(), ) write( @@ -134,6 +136,14 @@ class FriendStore( friend.copy(permissions = permissions) } + @Synchronized + fun linkMinecraftIdentity( + peerId: String, + minecraftUuid: UUID, + ): Either = update(peerId) { friend -> + friend.copy(minecraftUuid = minecraftUuid) + } + @Synchronized fun remove(peerId: String): Boolean { val current = read() @@ -199,6 +209,8 @@ class FriendStore( val capability = json.requiredString("capability") val connectAddress = json.optionalString("connectAddress") val displayName = json.requiredString("displayName") + val minecraftUuid = json.optionalString("minecraftUuid") + ?.let(UUID::fromString) if ( peerId.isBlank() || publicKey.isBlank() || @@ -217,6 +229,7 @@ class FriendStore( capability = capability, connectAddress = connectAddress, displayName = displayName, + minecraftUuid = minecraftUuid, permissions = FriendPermissions( notifyWhenOnline = permissions.requiredBoolean("notifyWhenOnline"), canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), @@ -242,6 +255,9 @@ class FriendStore( addProperty("connectAddress", it) } addProperty("displayName", friend.displayName) + friend.minecraftUuid?.let { + addProperty("minecraftUuid", it.toString()) + } add( "permissions", JsonObject().apply { 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 index 0e435d768..5af01224c 100644 --- 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 @@ -166,15 +166,43 @@ class AdmissionControllerTest { 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()) + } + private fun kotlinx.coroutines.test.TestScope.controller( connectedCount: () -> Int = { 0 }, maxGuests: () -> Int = { 8 }, + autoApprove: (AdmissionIdentity) -> Boolean = { false }, ) = AdmissionController( scope = backgroundScope, timeout = 30.seconds, maxPending = 16, connectedCount = connectedCount, maxGuests = maxGuests, + autoApprove = autoApprove, ) private fun authenticated( 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/friend/FriendStoreTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendStoreTest.kt index b5c932705..106ea2738 100644 --- 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 @@ -69,6 +69,24 @@ class FriendStoreTest { assertTrue(managed.permissions.canJoinAutomatically) } + @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) 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 index d65b6cd5f..e1bc245a9 100644 --- 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 @@ -1,29 +1,51 @@ package com.minekube.connect.share.fabric.v1_21_11 +import com.minekube.connect.share.admission.NewAdmissionTracker import com.minekube.connect.share.fabric.ConnectShareClient 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.FriendOnlineTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch 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.network.chat.Component class ConnectShare12111Client : ClientModInitializer { override fun onInitializeClient() { val client = Minecraft.getInstance() val dispatcher = client.asCoroutineDispatcher() val scope = CoroutineScope(SupervisorJob() + dispatcher) + val dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val remotePresence = FriendPresenceMonitor(friendStore) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } val installation = FabricShareBootstrap.create( scope = scope, - dataDirectory = FabricLoader.getInstance().configDir - .resolve("minekube-connect-share"), + dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), worldAvailable = client.hasSingleplayerServer(), playerCount = { @@ -33,10 +55,13 @@ class ConnectShare12111Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world" }, - bridgeFactory = { admission, admissionScope -> + bridgeFactory = { admission, admissionScope, approvedJoins -> Minecraft12111Bridge { FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission(admission), + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), scope = admissionScope, ) } @@ -56,11 +81,30 @@ class ConnectShare12111Client : ClientModInitializer { guestScreens = { parent -> val parentScreen = parent as Screen client.execute { - client.setScreen(ShareJoinScreen(parentScreen)) + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = FriendsViewModel( + friendStore, + ), + browser = FabricShareBrowser(dataDirectory), + remotePresence = remotePresence, + ), + ) } }, ) + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = FriendCardReceiver(friendStore), + approvedJoins = installation.approvedJoins, + ) ConnectShareClient.install(installation) + val admissionNotifications = NewAdmissionTracker() + val friendNotifications = FriendOnlineTracker() + val admissionToastId = SystemToast.SystemToastId() + val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> ConnectShareClient.integratedWorldChanged( @@ -70,9 +114,44 @@ class ConnectShare12111Client : ClientModInitializer { ConnectShareClient.guestConnectionChanged( minecraft.connection != null, ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.toastManager, + admissionToastId, + Component.translatable( + "connect_share.notification.join_request", + ), + Component.translatable( + "connect_share.notification.join_request_detail", + request.identity.name, + ), + ) + } + friendNotifications.update( + remotePresence.state.value, + ).firstOrNull()?.let { friend -> + SystemToast.add( + minecraft.toastManager, + friendToastId, + Component.translatable( + "connect_share.notification.friend_online", + ), + Component.translatable( + "connect_share.notification.friend_online_detail", + friend.displayName, + ), + ) + } } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() + scope.cancel() } } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 30_000L + } } 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..a781e05ba --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt @@ -0,0 +1,92 @@ +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, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name(), + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + ) + } + } + 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 -> + if (!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), + ) + } + } + } + } + } + } +} 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..a2a74ea40 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayload.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +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, +) : 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 -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + ) + }, + ) + } +} + +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/Minecraft12111LoginBridge.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/Minecraft12111LoginBridge.kt index a23ec7a7b..8974cff9e 100644 --- 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 @@ -124,6 +124,7 @@ object Minecraft12111LoginBridge { connectionId = session.connectionId(), minecraftAuthenticated = minecraftAuthenticated, ingress = session.route().toIngress(), + directPeerId = session.peerId(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) 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 index 0f6a77f31..be999fb51 100644 --- 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 @@ -1,9 +1,13 @@ package com.minekube.connect.share.fabric.v1_21_11 import com.minekube.connect.share.fabric.ConnectShareClient -import com.minekube.connect.share.fabric.DiscoveredLanShare import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -16,6 +20,7 @@ import net.minecraft.client.gui.components.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -24,84 +29,219 @@ import net.minecraft.network.chat.Component class ShareJoinScreen( private val parent: Screen, -) : Screen(Component.translatable("connect_share.join.title")) { - private val browser = FabricShareBrowser() + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, +) : 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 joinButton: Button? = null - private var invitationValue = "" - private var selectedLanAddress: String? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null private var safeMessage: String? = null - private var discoveredFingerprint = 0 + private var fingerprint = 0 private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false private var transferred = false - private var selectingDiscovered = false override fun init() { if (scope == null) { scope = CoroutineScope( - SupervisorJob() + minecraft!!.asCoroutineDispatcher(), + SupervisorJob() + minecraft.asCoroutineDispatcher(), ) browser.start().onLeft { safeMessage = it.safeMessage } } - discoveredFingerprint = browser.discovered.value.hashCode() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + when (mode) { + Mode.FRIENDS -> minecraft.setScreen(parent) + Mode.ADD, + Mode.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } - addRenderableWidget(centered(title, 16)) + private fun buildFriends() { addRenderableWidget( centered( - Component.translatable("connect_share.join.description"), + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.description"), 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + + val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) + if (saved.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.empty"), + 82, + ).setMaxWidth(CONTENT_WIDTH), + ) + } else { + saved.forEachIndexed { index, friend -> + val y = 58 + index * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 54) + .setMaxWidth(CONTENT_WIDTH), + ) + } + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, ), ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add_description"), + 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) invitationBox = addRenderableWidget( EditBox( font, width / 2 - 155, - 52, + 84, 310, 20, Component.translatable("connect_share.join.invitation"), ).apply { setMaxLength(MAX_INVITATION_LENGTH) - setHint(Component.translatable("connect_share.join.invitation_hint")) + setHint( + Component.translatable( + "connect_share.join.invitation_hint", + ), + ) setValue(invitationValue) - setResponder { value -> - invitationValue = value - if (!selectingDiscovered) { - selectedLanAddress = null - } + setResponder { + invitationValue = it refresh() } }, ) - - val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) - if (discovered.isEmpty()) { - addRenderableWidget( - centered( - Component.translatable("connect_share.join.scanning"), - 88, - ), - ) - } else { - discovered.forEachIndexed { index, share -> - addRenderableWidget( - Button.builder(discoveredLabel(share)) { - selectDiscovered(share) - }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) - .build(), - ) - } - } - offlineMode = addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.join.offline"), font, - ).pos(width / 2 - 155, 134) - .selected(offlineMode?.selected() ?: false) + ).pos(width / 2 - 155, 112) + .selected(offlineSelected) + .onValueChange { _, selected -> + offlineSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -115,8 +255,11 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.join.internet"), font, - ).pos(width / 2 - 155, 156) - .selected(internetDirect?.selected() ?: false) + ).pos(width / 2 - 155, 134) + .selected(internetSelected) + .onValueChange { _, selected -> + internetSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -126,91 +269,211 @@ class ShareJoinScreen( ) .build(), ) - - safeMessage?.let { - addRenderableWidget( - centered(Component.literal(it), 182).setMaxWidth(310), - ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - joinButton = addRenderableWidget( - Button.builder(Component.translatable("connect_share.join.join")) { - join() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save"), + ) { + if (friends.accept(invitationValue, nameValue)) { + scope?.launch { + remotePresence.refresh() + } + invitationValue = "" + nameValue = "" + mode = Mode.FRIENDS + rebuildWidgets() + } else { + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) refresh() } - override fun tick() { - super.tick() - val next = browser.discovered.value.hashCode() - if (next != discoveredFingerprint) { - invitationValue = invitationBox?.value.orEmpty() + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS rebuildWidgets() - } else { - refresh() + return } - } - - override fun onClose() { - minecraft?.setScreen(parent) - } - - override fun removed() { - scope?.cancel() - scope = null - if (!transferred) { - browser.close() + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 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(width / 2 - 155, 82) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + val autoJoin = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.auto_join"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canJoinAutomatically) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.auto_join.tooltip", + ), + ), + ) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 138) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - super.removed() + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = autoJoin.selected(), + ), + ) + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + minecraft.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + friends.remove(friend.peerId) + mode = Mode.FRIENDS + selectedPeerId = null + } + minecraft.setScreen(this) + }, + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + ), + ) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() } - private fun selectDiscovered(share: DiscoveredLanShare) { - selectedLanAddress = share.lanAddress - invitationValue = share.invitationUri - selectingDiscovered = true - invitationBox?.value = invitationValue - selectingDiscovered = false + 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(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } } - private fun join() { + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true + joiningPeerId = null + reciprocalPairing = false safeMessage = null refresh() scope?.launch { browser.join( invitationUri = invitationValue, - lanAddress = selectedLanAddress, - internetOptIn = internetDirect?.selected() == true, - authMode = if (offlineMode?.selected() == true) { - DirectP2pAuthMode.OFFLINE - } else { - DirectP2pAuthMode.ONLINE - }, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), ).fold( - ifLeft = { failure -> - joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, + ifLeft = ::joinFailed, ifRight = ::connect, ) } } + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.safeMessage + rebuildWidgets() + } + private fun connect(target: GuestJoinTarget) { - val client = minecraft ?: run { - target.close() - joining = false - return - } + val client = minecraft val address = when (target) { is GuestJoinTarget.Connect -> ServerAddress.parseString(target.publicAddress) @@ -227,24 +490,92 @@ class ShareJoinScreen( } else { browser.close() } + val joiningFriend = friends.state.value.friends.firstOrNull { + it.peerId == joiningPeerId + } val data = ServerData( - "Connect Share", + joiningFriend?.displayName ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) - ConnectScreen.startConnecting(parent, client, address, data, false, null) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds, + ) + if (exchangeFriendCard) { + ConnectShareClient.armFriendCardExchange() + } + ConnectScreen.startConnecting( + parent, + client, + address, + data, + false, + null, + ) } private fun refresh() { - joinButton?.active = !joining && invitationValue.isNotBlank() + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = !joining && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) } - private fun discoveredLabel(share: DiscoveredLanShare): Component = - Component.translatable( - "connect_share.join.discovered", - share.displayName, - ) + private fun friendLabel(friend: FriendSummary): Component = when { + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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) @@ -258,8 +589,15 @@ class ShareJoinScreen( ) } + private enum class Mode { + FRIENDS, + ADD, + MANAGE, + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_SHARES = 2 + const val MAX_VISIBLE_FRIENDS = 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/ShareSetupScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareSetupScreen.kt index d5eb538d6..f3c900d47 100644 --- 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 @@ -19,17 +19,17 @@ class ShareSetupScreen( override fun init() { val current = viewModel.state.value - minecraft?.singleplayerServer?.let { server -> + minecraft.singleplayerServer?.let { server -> viewModel.setGameMode(server.defaultGameType.toShareGameMode()) viewModel.setAllowCheats(server.worldData.isAllowCommands) } - addRenderableWidget(centered(title, 32)) + addRenderableWidget(centered(title, 18)) addRenderableWidget( centered( Component.translatable("connect_share.setup.description"), - 52, - ), + 36, + ).setMaxWidth(CONTENT_WIDTH), ) addRenderableWidget( CycleButton.builder( @@ -40,7 +40,7 @@ class ShareSetupScreen( ).withValues(ShareGameMode.entries) .create( width / 2 - 155, - 78, + 68, 150, 20, Component.translatable("selectWorld.gameMode"), @@ -50,7 +50,7 @@ class ShareSetupScreen( CycleButton.onOffBuilder(current.options.allowCheats) .create( width / 2 + 5, - 78, + 68, 150, 20, Component.translatable("selectWorld.allowCommands"), @@ -63,7 +63,7 @@ class ShareSetupScreen( ).withValues((1..16).toList()) .create( width / 2 - 75, - 110, + 96, 150, 20, Component.translatable("connect_share.setup.max_guests"), @@ -73,7 +73,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 138) + ).pos(width / 2 - 155, 126) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,12 +87,20 @@ class ShareSetupScreen( ) .build(), ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ).setMaxWidth(CONTENT_WIDTH), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), ) { viewModel.start() - minecraft?.setScreen(ShareStatusScreen(parent)) + minecraft.setScreen(ShareStatusScreen(parent)) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -109,7 +117,7 @@ class ShareSetupScreen( } override fun onClose() { - minecraft?.setScreen(parent) + minecraft.setScreen(parent) } private fun refresh() { @@ -120,6 +128,10 @@ class ShareSetupScreen( val textWidth = font.width(message) return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + + private companion object { + const val CONTENT_WIDTH = 310 + } } private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { 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 index 15335c29a..d820d99de 100644 --- 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 @@ -43,44 +43,57 @@ class ShareStatusScreen( Component.translatable("connect_share.status.copy_invitation"), ) { sharing?.invitation?.let( - minecraft!!.keyboardHandler::setClipboard, + minecraft.keyboardHandler::setClipboard, ) - }.bounds(width / 2 - 155, 48, 150, 20).build(), + }.bounds(width / 2 - 155, 50, 150, 20).build(), ) copyInvitation.active = sharing?.invitation != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), ) { - sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 48, 150, 20).build(), + sharing?.address?.let(minecraft.keyboardHandler::setClipboard) + }.bounds(width / 2 + 5, 50, 150, 20).build(), ) copyAddress.active = sharing?.address != null - sharing?.let { + if (sharing != null) { addRenderableWidget( centered( Component.translatable( - "connect_share.status.routes", - availability(it.connectAvailable), - availability(it.lanDirectAvailable), - availability(it.internetDirectAvailable), + "connect_share.status.link_help", ), - 76, - ).setMaxWidth(310), + 78, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ).setMaxWidth(CONTENT_WIDTH), ) } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft?.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 92, 200, 20).build(), + minecraft.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 166) / 38).coerceIn(1, 3) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 120 + index * 38 + val y = 124 + index * 26 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> listOfNotNull( @@ -95,7 +108,6 @@ class ShareStatusScreen( val label = Component.translatable( "connect_share.status.request", identity.name, - identity.uuid.toString(), badge, ) addRenderableWidget( @@ -126,7 +138,7 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 120 + visibleRows * 38, + 124 + visibleRows * 26, ), ) } else if (pending.isEmpty()) { @@ -141,7 +153,7 @@ class ShareStatusScreen( addRenderableWidget( Button.builder(Component.translatable("connect_share.status.stop")) { viewModel.stop() - minecraft?.setScreen(parent) + minecraft.setScreen(parent) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -160,7 +172,7 @@ class ShareStatusScreen( } override fun onClose() { - minecraft?.setScreen(parent) + minecraft.setScreen(parent) } private fun centered(message: Component, y: Int): StringWidget { @@ -168,9 +180,6 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } - private fun availability(available: Boolean): Component = - Component.translatable(if (available) "options.on" else "options.off") - private fun Ingress.displayName(): String = when (this) { Ingress.CONNECT -> "connect" Ingress.DIRECT_LAN -> "lan" @@ -184,4 +193,8 @@ class ShareStatusScreen( ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } + + private companion object { + const val CONTENT_WIDTH = 310 + } } 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 index 5cc09956c..1653c6127 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Mit Connect teilen", - "connect_share.menu.active": "Connect Share aktiv", - "connect_share.menu.join": "Connect Share beitreten", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", "connect_share.setup.max_guests": "Maximale Gäste", - "connect_share.setup.internet": "Direkte Internetverbindungen erlauben", + "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.start": "Teilen starten", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Mit Freunden teilen", "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": "Connect Share", - "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", - "connect_share.status.copy_invitation": "Einladung kopieren", - "connect_share.status.copy_address": "Vanilla-Adresse kopieren", - "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", + "connect_share.status.title": "Welt ist für Freunde 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": "Beitrittsanfragen", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Erlauben", "connect_share.status.deny": "Ablehnen", "connect_share.status.more": "%s weitere Anfragen", - "connect_share.status.waiting": "Warte auf Freunde…", - "connect_share.status.stop": "Teilen beenden", + "connect_share.status.waiting": "Niemand wartet auf den Beitritt.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpunkt-Identität…", + "connect_share.friends.title": "Freunde", + "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.empty": "Noch keine Freunde. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.save": "Freund speichern", + "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.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.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": "Freund möchte beitreten", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", + "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", 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 index b0a048bbb..7abb70291 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Share with Connect", - "connect_share.menu.active": "Connect Share active", - "connect_share.menu.join": "Join Connect Share", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", "connect_share.setup.max_guests": "Maximum guests", - "connect_share.setup.internet": "Allow direct internet connections", + "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.start": "Start sharing", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Share with friends", "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": "Connect Share", - "connect_share.status.address": "Join address: %s", - "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", - "connect_share.status.copy_invitation": "Copy invitation", - "connect_share.status.copy_address": "Copy vanilla address", - "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", + "connect_share.status.title": "World ready for friends", + "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": "Join requests", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Allow", "connect_share.status.deny": "Deny", "connect_share.status.more": "%s more requests", - "connect_share.status.waiting": "Waiting for friends to join…", - "connect_share.status.stop": "Stop sharing", + "connect_share.status.waiting": "No one is waiting to join.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpoint identity…", + "connect_share.friends.title": "Friends", + "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.empty": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.manage": "Manage", + "connect_share.friends.add": "Add friend", + "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.name": "Friend name", + "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.save": "Save friend", + "connect_share.friends.join_once": "Join once", + "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.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.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": "Friend wants to join", + "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is online. Open Friends 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", 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 index 4dcbfedd2..bba0ddf50 100644 --- 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 @@ -17,6 +17,26 @@ 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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" in language, + ) + } + } + @Test fun `remapped artifact is self contained and isolates networking runtime`() { JarFile(artifact().toFile()).use { jar -> @@ -25,6 +45,10 @@ class Fabric12111ArtifactTest { 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/") && 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..79161088e --- /dev/null +++ b/share/fabric-1.21.11/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import io.netty.buffer.Unpooled +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 buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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/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 index c39cf33f9..34910bf48 100644 --- 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 @@ -1,19 +1,32 @@ package com.minekube.connect.share.fabric.v26_2 +import com.minekube.connect.share.admission.NewAdmissionTracker import com.minekube.connect.share.fabric.ConnectShareClient 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.FriendOnlineTracker +import com.minekube.connect.share.fabric.FriendPresenceMonitor +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch 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.network.chat.Component class ConnectShare262Client : ClientModInitializer { override fun onInitializeClient() { @@ -21,10 +34,19 @@ class ConnectShare262Client : ClientModInitializer { val scope = CoroutineScope( SupervisorJob() + client.asCoroutineDispatcher(), ) + val dataDirectory = FabricLoader.getInstance().configDir + .resolve("minekube-connect-share") + val friendStore = FriendStore(dataDirectory) + val remotePresence = FriendPresenceMonitor(friendStore) + scope.launch { + while (isActive) { + remotePresence.refresh() + delay(PRESENCE_REFRESH_MILLIS) + } + } val installation = FabricShareBootstrap.create( scope = scope, - dataDirectory = FabricLoader.getInstance().configDir - .resolve("minekube-connect-share"), + dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), worldAvailable = client.hasSingleplayerServer(), playerCount = { @@ -34,10 +56,13 @@ class ConnectShare262Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world" }, - bridgeFactory = { admission, admissionScope -> + bridgeFactory = { admission, admissionScope, approvedJoins -> Minecraft262Bridge { FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission(admission), + admission = FabricLocalLoginAdmission( + admission, + approvedJoins, + ), scope = admissionScope, ) } @@ -57,11 +82,30 @@ class ConnectShare262Client : ClientModInitializer { guestScreens = { parent -> val parentScreen = parent as Screen client.execute { - client.gui.setScreen(ShareJoinScreen(parentScreen)) + client.gui.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = FriendsViewModel( + friendStore, + ), + browser = FabricShareBrowser(dataDirectory), + remotePresence = remotePresence, + ), + ) } }, ) + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = FriendCardReceiver(friendStore), + approvedJoins = installation.approvedJoins, + ) ConnectShareClient.install(installation) + val admissionNotifications = NewAdmissionTracker() + val friendNotifications = FriendOnlineTracker() + val admissionToastId = SystemToast.SystemToastId() + val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> ConnectShareClient.integratedWorldChanged( @@ -71,9 +115,44 @@ class ConnectShare262Client : ClientModInitializer { ConnectShareClient.guestConnectionChanged( minecraft.connection != null, ) + admissionNotifications.update( + installation.viewModel.state.value.pendingAdmissions, + ).firstOrNull()?.let { request -> + SystemToast.add( + minecraft.gui.toastManager(), + admissionToastId, + Component.translatable( + "connect_share.notification.join_request", + ), + Component.translatable( + "connect_share.notification.join_request_detail", + request.identity.name, + ), + ) + } + friendNotifications.update( + remotePresence.state.value, + ).firstOrNull()?.let { friend -> + SystemToast.add( + minecraft.gui.toastManager(), + friendToastId, + Component.translatable( + "connect_share.notification.friend_online", + ), + Component.translatable( + "connect_share.notification.friend_online_detail", + friend.displayName, + ), + ) + } } ClientLifecycleEvents.CLIENT_STOPPING.register { ConnectShareClient.shutdown() + scope.cancel() } } + + private companion object { + const val PRESENCE_REFRESH_MILLIS = 30_000L + } } 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..f5b6ef4e9 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt @@ -0,0 +1,92 @@ +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, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name(), + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + ) + } + } + 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 -> + if (!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), + ) + } + } + } + } + } + } +} 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..54f6f1350 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayload.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.fabric.v26_2 + +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, +) : 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 -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + ) + }, + ) + } +} + +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/Minecraft262LoginBridge.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/Minecraft262LoginBridge.kt index f3556f0ef..a68f2b899 100644 --- 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 @@ -124,6 +124,7 @@ object Minecraft262LoginBridge { connectionId = session.connectionId(), minecraftAuthenticated = minecraftAuthenticated, ingress = session.route().toIngress(), + directPeerId = session.peerId(), ).toCompletableFuture() channel.closeFuture().addListener { decision.cancel(false) 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 index 76fbafa2e..b0ee9b699 100644 --- 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 @@ -1,9 +1,13 @@ package com.minekube.connect.share.fabric.v26_2 import com.minekube.connect.share.fabric.ConnectShareClient -import com.minekube.connect.share.fabric.DiscoveredLanShare import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.FriendCardExchangeConsent +import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.ui.FriendSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -16,6 +20,7 @@ import net.minecraft.client.gui.components.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -24,20 +29,29 @@ import net.minecraft.network.chat.Component class ShareJoinScreen( private val parent: Screen, -) : Screen(Component.translatable("connect_share.join.title")) { - private val browser = FabricShareBrowser() + private val friends: FriendsViewModel, + private val browser: FabricShareBrowser, + private val remotePresence: FriendPresenceMonitor, +) : 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 joinButton: Button? = null - private var invitationValue = "" - private var selectedLanAddress: String? = null + private var primaryButton: Button? = null + private var secondaryButton: Button? = null private var safeMessage: String? = null - private var discoveredFingerprint = 0 + private var fingerprint = 0 private var joining = false + private var joiningPeerId: String? = null + private var reciprocalPairing = false private var transferred = false - private var selectingDiscovered = false override fun init() { if (scope == null) { @@ -46,62 +60,188 @@ class ShareJoinScreen( ) browser.start().onLeft { safeMessage = it.safeMessage } } - discoveredFingerprint = browser.discovered.value.hashCode() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + fingerprint = currentFingerprint() + nameBox = null + invitationBox = null + primaryButton = null + secondaryButton = null + + when (mode) { + Mode.FRIENDS -> buildFriends() + Mode.ADD -> buildAddFriend() + Mode.MANAGE -> buildManageFriend() + } + } + + override fun tick() { + super.tick() + friends.updatePresence(browser.discovered.value) + friends.updateRemotePresence(remotePresence.state.value) + val next = currentFingerprint() + if (next != fingerprint) { + rebuildWidgets() + } else { + refresh() + } + } + + override fun onClose() { + when (mode) { + Mode.FRIENDS -> minecraft.gui.setScreen(parent) + Mode.ADD, + Mode.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } - addRenderableWidget(centered(title, 16)) + override fun removed() { + scope?.cancel() + scope = null + if (!transferred) { + browser.close() + } + super.removed() + } + + private fun buildFriends() { addRenderableWidget( centered( - Component.translatable("connect_share.join.description"), + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.description"), 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + + val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) + if (saved.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.empty"), + 82, + ).setMaxWidth(CONTENT_WIDTH), + ) + } else { + saved.forEachIndexed { index, friend -> + val y = 58 + index * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 54) + .setMaxWidth(CONTENT_WIDTH), + ) + } + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, ), ) + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add_description"), + 34, + ).setMaxWidth(CONTENT_WIDTH), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) invitationBox = addRenderableWidget( EditBox( font, width / 2 - 155, - 52, + 84, 310, 20, Component.translatable("connect_share.join.invitation"), ).apply { setMaxLength(MAX_INVITATION_LENGTH) - setHint(Component.translatable("connect_share.join.invitation_hint")) + setHint( + Component.translatable( + "connect_share.join.invitation_hint", + ), + ) setValue(invitationValue) - setResponder { value -> - invitationValue = value - if (!selectingDiscovered) { - selectedLanAddress = null - } + setResponder { + invitationValue = it refresh() } }, ) - - val discovered = browser.discovered.value.take(MAX_VISIBLE_SHARES) - if (discovered.isEmpty()) { - addRenderableWidget( - centered( - Component.translatable("connect_share.join.scanning"), - 88, - ), - ) - } else { - discovered.forEachIndexed { index, share -> - addRenderableWidget( - Button.builder(discoveredLabel(share)) { - selectDiscovered(share) - }.bounds(width / 2 - 155, 80 + index * 24, 310, 20) - .build(), - ) - } - } - offlineMode = addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.join.offline"), font, - ).pos(width / 2 - 155, 134) - .selected(offlineMode?.selected() ?: false) + ).pos(width / 2 - 155, 112) + .selected(offlineSelected) + .onValueChange { _, selected -> + offlineSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -115,8 +255,11 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.join.internet"), font, - ).pos(width / 2 - 155, 156) - .selected(internetDirect?.selected() ?: false) + ).pos(width / 2 - 155, 134) + .selected(internetSelected) + .onValueChange { _, selected -> + internetSelected = selected + } .tooltip( Tooltip.create( Component.translatable( @@ -126,85 +269,209 @@ class ShareJoinScreen( ) .build(), ) - - safeMessage?.let { - addRenderableWidget( - centered(Component.literal(it), 182).setMaxWidth(310), - ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - joinButton = addRenderableWidget( - Button.builder(Component.translatable("connect_share.join.join")) { - join() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save"), + ) { + if (friends.accept(invitationValue, nameValue)) { + scope?.launch { + remotePresence.refresh() + } + invitationValue = "" + nameValue = "" + mode = Mode.FRIENDS + rebuildWidgets() + } else { + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) refresh() } - override fun tick() { - super.tick() - val next = browser.discovered.value.hashCode() - if (next != discoveredFingerprint) { - invitationValue = invitationBox?.value.orEmpty() + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS rebuildWidgets() - } else { - refresh() + return } - } - - override fun onClose() { - minecraft.gui.setScreen(parent) - } - - override fun removed() { - scope?.cancel() - scope = null - if (!transferred) { - browser.close() + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 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(width / 2 - 155, 82) + .selected(friend.permissions.notifyWhenOnline) + .build(), + ) + val autoJoin = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.auto_join"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canJoinAutomatically) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.auto_join.tooltip", + ), + ), + ) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 138) + .setMaxWidth(CONTENT_WIDTH), + ) + } } - super.removed() + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.save_changes"), + ) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = autoJoin.selected(), + ), + ) + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + minecraft.gui.setScreen( + ConfirmScreen( + { confirmed -> + if (confirmed) { + friends.remove(friend.peerId) + mode = Mode.FRIENDS + selectedPeerId = null + } + minecraft.gui.setScreen(this) + }, + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + ), + ) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() } - private fun selectDiscovered(share: DiscoveredLanShare) { - selectedLanAddress = share.lanAddress - invitationValue = share.invitationUri - selectingDiscovered = true - invitationBox?.value = invitationValue - selectingDiscovered = false + 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(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } } - private fun join() { + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true + joiningPeerId = null + reciprocalPairing = false safeMessage = null refresh() scope?.launch { browser.join( invitationUri = invitationValue, - lanAddress = selectedLanAddress, - internetOptIn = internetDirect?.selected() == true, - authMode = if (offlineMode?.selected() == true) { - DirectP2pAuthMode.OFFLINE - } else { - DirectP2pAuthMode.ONLINE - }, + lanAddress = null, + internetOptIn = internetSelected, + authMode = authMode(), ).fold( - ifLeft = { failure -> - joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, + ifLeft = ::joinFailed, ifRight = ::connect, ) } } + private fun joinFailed(failure: com.minekube.connect.share.fabric.GuestJoinFailure) { + joining = false + joiningPeerId = null + reciprocalPairing = false + safeMessage = failure.safeMessage + rebuildWidgets() + } + private fun connect(target: GuestJoinTarget) { val address = when (target) { is GuestJoinTarget.Connect -> @@ -222,24 +489,92 @@ class ShareJoinScreen( } else { browser.close() } + val joiningFriend = friends.state.value.friends.firstOrNull { + it.peerId == joiningPeerId + } val data = ServerData( - "Connect Share", + joiningFriend?.displayName ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) - ConnectScreen.startConnecting(parent, minecraft, address, data, false, null) + val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( + savedFriendJoin = reciprocalPairing, + canSeeMyWorlds = + joiningFriend?.permissions?.canSeeMyWorlds, + ) + if (exchangeFriendCard) { + ConnectShareClient.armFriendCardExchange() + } + ConnectScreen.startConnecting( + parent, + minecraft, + address, + data, + false, + null, + ) } private fun refresh() { - joinButton?.active = !joining && invitationValue.isNotBlank() + val inputReady = invitationValue.isNotBlank() + primaryButton?.active = !joining && + when (mode) { + Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) } - private fun discoveredLabel(share: DiscoveredLanShare): Component = - Component.translatable( - "connect_share.join.discovered", - share.displayName, - ) + private fun friendLabel(friend: FriendSummary): Component = when { + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun selectedFriend(): FriendSummary? = + friends.state.value.friends.firstOrNull { + it.peerId == selectedPeerId + } + + private fun safeMessage(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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) @@ -253,8 +588,15 @@ class ShareJoinScreen( ) } + private enum class Mode { + FRIENDS, + ADD, + MANAGE, + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_SHARES = 2 + const val MAX_VISIBLE_FRIENDS = 5 + const val CONTENT_WIDTH = 310 } } 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 index d0cb204d8..db90bca2e 100644 --- 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 @@ -24,12 +24,12 @@ class ShareSetupScreen( viewModel.setAllowCheats(server.worldData.isAllowCommands) } - addRenderableWidget(centered(title, 32)) + addRenderableWidget(centered(title, 18)) addRenderableWidget( centered( Component.translatable("connect_share.setup.description"), - 52, - ), + 36, + ).setMaxWidth(CONTENT_WIDTH), ) addRenderableWidget( CycleButton.builder( @@ -40,7 +40,7 @@ class ShareSetupScreen( ).withValues(ShareGameMode.entries) .create( width / 2 - 155, - 78, + 68, 150, 20, Component.translatable("selectWorld.gameMode"), @@ -50,7 +50,7 @@ class ShareSetupScreen( CycleButton.onOffBuilder(current.options.allowCheats) .create( width / 2 + 5, - 78, + 68, 150, 20, Component.translatable("selectWorld.allowCommands"), @@ -63,7 +63,7 @@ class ShareSetupScreen( ).withValues((1..16).toList()) .create( width / 2 - 75, - 110, + 96, 150, 20, Component.translatable("connect_share.setup.max_guests"), @@ -73,7 +73,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 138) + ).pos(width / 2 - 155, 126) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,6 +87,14 @@ class ShareSetupScreen( ) .build(), ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ).setMaxWidth(CONTENT_WIDTH), + ) startButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.setup.start"), @@ -120,6 +128,10 @@ class ShareSetupScreen( val textWidth = font.width(message) return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } + + private companion object { + const val CONTENT_WIDTH = 310 + } } private fun net.minecraft.world.level.GameType.toShareGameMode(): ShareGameMode = when (this) { 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 index 98c096891..c3b3ace0d 100644 --- 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 @@ -45,7 +45,7 @@ class ShareStatusScreen( sharing?.invitation?.let( minecraft.keyboardHandler::setClipboard, ) - }.bounds(width / 2 - 155, 48, 150, 20).build(), + }.bounds(width / 2 - 155, 50, 150, 20).build(), ) copyInvitation.active = sharing?.invitation != null val copyAddress = addRenderableWidget( @@ -53,34 +53,47 @@ class ShareStatusScreen( Component.translatable("connect_share.status.copy_address"), ) { sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 48, 150, 20).build(), + }.bounds(width / 2 + 5, 50, 150, 20).build(), ) copyAddress.active = sharing?.address != null - sharing?.let { + if (sharing != null) { addRenderableWidget( centered( Component.translatable( - "connect_share.status.routes", - availability(it.connectAvailable), - availability(it.lanDirectAvailable), - availability(it.internetDirectAvailable), + "connect_share.status.link_help", ), - 76, - ).setMaxWidth(310), + 78, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ).setMaxWidth(CONTENT_WIDTH), ) } addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 100, 92, 200, 20).build(), + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions - val visibleRows = ((height - 166) / 38).coerceIn(1, 3) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) pending.take(visibleRows).forEachIndexed { index, request -> - val y = 120 + index * 38 + val y = 124 + index * 26 val identity = request.identity val badge = when (identity) { is AdmissionIdentity.Authenticated -> listOfNotNull( @@ -95,7 +108,6 @@ class ShareStatusScreen( val label = Component.translatable( "connect_share.status.request", identity.name, - identity.uuid.toString(), badge, ) addRenderableWidget( @@ -126,7 +138,7 @@ class ShareStatusScreen( "connect_share.status.more", pending.size - visibleRows, ), - 120 + visibleRows * 38, + 124 + visibleRows * 26, ), ) } else if (pending.isEmpty()) { @@ -168,9 +180,6 @@ class ShareStatusScreen( return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) } - private fun availability(available: Boolean): Component = - Component.translatable(if (available) "options.on" else "options.off") - private fun Ingress.displayName(): String = when (this) { Ingress.CONNECT -> "connect" Ingress.DIRECT_LAN -> "lan" @@ -184,4 +193,8 @@ class ShareStatusScreen( ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } + + private companion object { + const val CONTENT_WIDTH = 310 + } } 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 index 5cc09956c..1653c6127 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Mit Connect teilen", - "connect_share.menu.active": "Connect Share aktiv", - "connect_share.menu.join": "Connect Share beitreten", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Lade Freunde ein, ohne deine Welt im LAN zu öffnen.", + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", "connect_share.setup.max_guests": "Maximale Gäste", - "connect_share.setup.internet": "Direkte Internetverbindungen erlauben", + "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.start": "Teilen starten", + "connect_share.setup.persistence": "Beim Wechseln oder erneuten Öffnen einer Welt wird das Teilen automatisch fortgesetzt.", + "connect_share.setup.start": "Mit Freunden teilen", "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": "Connect Share", - "connect_share.status.address": "Beitrittsadresse: %s", - "connect_share.status.direct_only": "Direkte Freigabe aktiv; Connect ist nicht verfügbar", - "connect_share.status.copy_invitation": "Einladung kopieren", - "connect_share.status.copy_address": "Vanilla-Adresse kopieren", - "connect_share.status.routes": "Connect: %s · LAN direkt: %s · Internet direkt: %s", + "connect_share.status.title": "Welt ist für Freunde 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": "Beitrittsanfragen", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Erlauben", "connect_share.status.deny": "Ablehnen", "connect_share.status.more": "%s weitere Anfragen", - "connect_share.status.waiting": "Warte auf Freunde…", - "connect_share.status.stop": "Teilen beenden", + "connect_share.status.waiting": "Niemand wartet auf den Beitritt.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpunkt-Identität…", + "connect_share.friends.title": "Freunde", + "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.empty": "Noch keine Freunde. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.manage": "Verwalten", + "connect_share.friends.add": "Freund hinzufügen", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.name": "Name des Freundes", + "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.save": "Freund speichern", + "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.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.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": "Freund möchte beitreten", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", + "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", + "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", 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 index b0a048bbb..7abb70291 100644 --- 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 @@ -1,34 +1,37 @@ { - "connect_share.menu.share": "Share with Connect", - "connect_share.menu.active": "Connect Share active", - "connect_share.menu.join": "Join Connect Share", - "connect_share.setup.title": "Connect Share", - "connect_share.setup.description": "Invite friends without opening your world to the LAN.", + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", "connect_share.setup.max_guests": "Maximum guests", - "connect_share.setup.internet": "Allow direct internet connections", + "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.start": "Start sharing", + "connect_share.setup.persistence": "Sharing resumes automatically when you switch or reopen a world.", + "connect_share.setup.start": "Share with friends", "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": "Connect Share", - "connect_share.status.address": "Join address: %s", - "connect_share.status.direct_only": "Direct sharing active; Connect is unavailable", - "connect_share.status.copy_invitation": "Copy invitation", - "connect_share.status.copy_address": "Copy vanilla address", - "connect_share.status.routes": "Connect: %s · LAN direct: %s · Internet direct: %s", + "connect_share.status.title": "World ready for friends", + "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": "Join requests", "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 · %s · %s", + "connect_share.status.request": "%s · %s", "connect_share.status.allow": "Allow", "connect_share.status.deny": "Deny", "connect_share.status.more": "%s more requests", - "connect_share.status.waiting": "Waiting for friends to join…", - "connect_share.status.stop": "Stop sharing", + "connect_share.status.waiting": "No one is waiting to join.", + "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", @@ -40,7 +43,34 @@ "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.identity.manage": "Endpoint identity…", + "connect_share.friends.title": "Friends", + "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.empty": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.manage": "Manage", + "connect_share.friends.add": "Add friend", + "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.name": "Friend name", + "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.save": "Save friend", + "connect_share.friends.join_once": "Join once", + "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.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.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": "Friend wants to join", + "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.friend_online": "Friend's world is ready", + "connect_share.notification.friend_online_detail": "%s is online. Open Friends 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", 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 index 5f0854e16..3abd08604 100644 --- 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 @@ -17,6 +17,26 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue class Fabric262ArtifactTest { + @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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" in language, + ) + } + } + @Test fun `artifact is self contained and isolates networking runtime`() { JarFile(artifact().toFile()).use { jar -> @@ -25,6 +45,10 @@ class Fabric262ArtifactTest { 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/") && 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..90b026376 --- /dev/null +++ b/share/fabric-26.2/src/test/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.v26_2 + +import io.netty.buffer.Unpooled +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 buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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-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..6e405f5e8 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTracker.kt @@ -0,0 +1,86 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionAnswer +import com.minekube.connect.share.admission.AdmissionIdentity +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, + ), + 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 true + } + + fun consume( + name: String, + uuid: UUID, + ): ApprovedJoinProof? { + val timedProof = approved.remove( + PlayerKey(name.normalized(), uuid), + ) ?: return null + return timedProof.proof.takeIf { + nowMillis() - timedProof.approvedAtMillis <= + PROOF_LIFETIME_MILLIS + } + } + + 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 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/ConnectShareClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectShareClient.kt index e4e7a4ca2..50412d4fd 100644 --- 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 @@ -14,6 +14,8 @@ fun interface ConnectShareGuestScreenFactory { data class ConnectShareInstallation( val viewModel: ShareViewModel, val runtime: ConnectShareRuntime, + val friendCardIssuer: FriendCardIssuer, + val approvedJoins: ApprovedJoinTracker, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -22,6 +24,7 @@ object ConnectShareClient { @Volatile private var installation: ConnectShareInstallation? = null private val guestLease = GuestConnectionLease() + private val friendCardConsent = FriendCardExchangeConsent() fun install(value: ConnectShareInstallation) { check(installation == null) { @@ -69,6 +72,19 @@ object ConnectShareClient { fun viewModel(): ShareViewModel = checkNotNull(installation).viewModel + @JvmStatic + fun friendCardIssuer(): FriendCardIssuer = + checkNotNull(installation).friendCardIssuer + + @JvmStatic + fun armFriendCardExchange() { + friendCardConsent.arm() + } + + @JvmStatic + fun consumeFriendCardExchangeConsent(): Boolean = + friendCardConsent.consume() + @JvmStatic fun integratedWorldChanged( worldAvailable: Boolean, @@ -79,6 +95,7 @@ object ConnectShareClient { @JvmStatic fun shutdown() { + friendCardConsent.cancel() guestLease.close() installation?.runtime?.shutdown() } 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 index 9fcc9b143..07d36bf94 100644 --- 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 @@ -33,6 +33,7 @@ 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 runtimeFactory: FabricConnectRuntimeFactory, ) : ConnectShareIngress { @@ -42,10 +43,12 @@ class FabricConnectIngress private constructor( logger: ConnectLogger, platformUtils: FabricPlatformUtils, admission: AdmissionController, + approvedJoins: ApprovedJoinTracker, scope: CoroutineScope, ) : this( dataDirectory = dataDirectory, admission = admission, + approvedJoins = approvedJoins, scope = scope, runtimeFactory = GuiceFabricConnectRuntimeFactory( dataDirectory = dataDirectory, @@ -72,7 +75,11 @@ class FabricConnectIngress private constructor( "Connect endpoint identity changed before sharing started" } - val gate = FabricSessionAdmissionGate(admission, scope) + val gate = FabricSessionAdmissionGate( + admission, + scope, + approvedJoins, + ) val runtime = try { runtimeFactory.start(identity, target, gate) } catch (failure: Throwable) { @@ -98,9 +105,12 @@ class FabricConnectIngress private constructor( admission: AdmissionController, scope: CoroutineScope, runtimeFactory: FabricConnectRuntimeFactory, + approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), ) = FabricConnectIngress( dataDirectory = dataDirectory, admission = admission, + approvedJoins = approvedJoins, scope = scope, runtimeFactory = runtimeFactory, ) 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 index 108ccf6c2..4d5946854 100644 --- 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 @@ -28,6 +28,7 @@ object FabricLoginAdmissionRegistry { connectionId: String, minecraftAuthenticated: Boolean, ingress: Ingress, + directPeerId: String? = null, ): CompletionStage { val gate = installed.get() if (gate == null) { @@ -39,6 +40,7 @@ object FabricLoginAdmissionRegistry { connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, ingress = ingress, + directPeerId = directPeerId, ) } } 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 index ca50fbd30..9c9bdcfbf 100644 --- 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 @@ -25,6 +25,8 @@ import kotlinx.coroutines.launch class FabricSessionAdmissionGate( private val admission: AdmissionController, private val scope: CoroutineScope, + private val approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), ) : SessionAdmissionGate { private val stopped = AtomicBoolean() private val active = ConcurrentHashMap, Job>() @@ -49,7 +51,9 @@ class FabricSessionAdmissionGate( lateinit var job: Job job = scope.launch(start = CoroutineStart.LAZY) { try { - future.complete(admission.request(identity).toCoreDecision()) + val answer = admission.request(identity) + approvedJoins.record(identity, answer) + future.complete(answer.toCoreDecision()) } catch (cancellation: CancellationException) { future.cancel(false) throw cancellation @@ -126,6 +130,8 @@ class FabricSessionAdmissionGate( class FabricLocalLoginAdmission( private val admission: AdmissionController, + private val approvedJoins: ApprovedJoinTracker = + ApprovedJoinTracker(), ) { suspend fun request( name: String, @@ -133,6 +139,7 @@ class FabricLocalLoginAdmission( connectionId: String, minecraftAuthenticated: Boolean, ingress: Ingress = Ingress.CONNECT, + directPeerId: String? = null, ): AdmissionAnswer { val identity = if (minecraftAuthenticated) { AdmissionIdentity.Authenticated( @@ -140,6 +147,7 @@ class FabricLocalLoginAdmission( uuid = uuid, source = AuthSource.MOJANG, ingress = ingress, + directPeerId = directPeerId, ) } else { AdmissionIdentity.UnverifiedOffline( @@ -147,9 +155,12 @@ class FabricLocalLoginAdmission( uuid = uuid, connectionId = connectionId, ingress = ingress, + directPeerId = directPeerId, ) } - return admission.request(identity) + return admission.request(identity).also { answer -> + approvedJoins.record(identity, answer) + } } } @@ -166,6 +177,7 @@ class FabricLocalLoginAdmissionGate( connectionId: String, minecraftAuthenticated: Boolean, ingress: Ingress = Ingress.CONNECT, + directPeerId: String? = null, ): CompletionStage { val future = CompletableFuture() if (stopped.get()) { @@ -183,6 +195,7 @@ class FabricLocalLoginAdmissionGate( connectionId = connectionId, minecraftAuthenticated = minecraftAuthenticated, ingress = ingress, + directPeerId = directPeerId, ), ) } catch (cancellation: CancellationException) { 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 index efde07026..89fe2f6fd 100644 --- 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 @@ -5,8 +5,10 @@ import com.minekube.connect.identity.EndpointTokenStore import com.minekube.connect.share.ShareCoordinator 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.fabric.ui.ShareViewModel import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions +import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -30,7 +32,11 @@ object FabricShareBootstrap { playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, bridgeFactory: - (AdmissionController, CoroutineScope) -> VersionedMinecraftBridge, + ( + AdmissionController, + CoroutineScope, + ApprovedJoinTracker, + ) -> VersionedMinecraftBridge, screens: ConnectShareScreenFactory, guestScreens: ConnectShareGuestScreenFactory, environment: Map = System.getenv(), @@ -38,6 +44,8 @@ object FabricShareBootstrap { httpClient: OkHttpClient = OkHttpClient(), ): ConnectShareInstallation { val viewModelReference = AtomicReference() + val friendStore = FriendStore(dataDirectory) + val approvedJoins = ApprovedJoinTracker() val admission = AdmissionController( scope = scope, connectedCount = { @@ -47,8 +55,26 @@ object FabricShareBootstrap { 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 bridge = bridgeFactory( + admission, + scope, + approvedJoins, ) - val bridge = bridgeFactory(admission, scope) val identityStore = EndpointIdentityStore( directory = dataDirectory, environment = environment, @@ -76,6 +102,7 @@ object FabricShareBootstrap { playerCount = playerCount, ), admission = admission, + approvedJoins = approvedJoins, scope = scope, ) val directIngress = FabricDirectShareIngress( @@ -122,6 +149,10 @@ object FabricShareBootstrap { return ConnectShareInstallation( viewModel = viewModel, runtime = runtime, + friendCardIssuer = FriendCardIssuer(dataDirectory) { + "${identityStore.currentOrCreate().endpoint}.play.minekube.net" + }, + approvedJoins = approvedJoins, screens = screens, guestScreens = guestScreens, ) 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 index 96c3c7405..14affbdcd 100644 --- 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 @@ -9,14 +9,17 @@ 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -96,6 +99,14 @@ class FabricShareBrowser private constructor( ioDispatcher = Dispatchers.IO, ) + constructor(dataDirectory: Path) : this( + node = CoreFabricGuestDirectNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ), + now = Instant::now, + ioDispatcher = Dispatchers.IO, + ) + private val mutableDiscovered = MutableStateFlow>(emptyList()) private val started = AtomicBoolean() @@ -103,6 +114,8 @@ class FabricShareBrowser private constructor( val discovered: StateFlow> = mutableDiscovered.asStateFlow() + val peerId: String + get() = node.peerId() fun start(): Either { if (started.get()) { @@ -178,6 +191,27 @@ class FabricShareBrowser private constructor( } } + suspend fun join( + 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() } + } + friend.connectAddress?.let { + return@withContext GuestJoinTarget.Connect(it).right() + } + GuestJoinFailure.NoRoute.left() + } + override fun close() { if (closed.compareAndSet(false, true)) { node.close() @@ -217,18 +251,44 @@ class FabricShareBrowser private constructor( }?.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 payload = invitation.payload val proxy = node.openProxy( address = address, - shareId = payload.shareId.toString(), - capability = payload.capability, + shareId = shareId, + capability = capability, authMode = authMode, timeout = timeout, ) @@ -251,10 +311,13 @@ class FabricShareBrowser private constructor( 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" } } internal interface FabricGuestDirectNode : AutoCloseable { + fun peerId(): String + fun startDiscovery(listener: DirectP2pDiscoveryListener) fun openProxy( @@ -269,6 +332,8 @@ internal interface FabricGuestDirectNode : AutoCloseable { private class CoreFabricGuestDirectNode( private val node: DirectP2pNode, ) : FabricGuestDirectNode { + override fun peerId(): String = node.peerId() + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { node.startDiscovery(listener) } 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..e49d3c605 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsent.kt @@ -0,0 +1,34 @@ +package com.minekube.connect.share.fabric + +class FriendCardExchangeConsent( + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private var armedAtMillis: Long? = null + + @Synchronized + fun arm() { + armedAtMillis = nowMillis() + } + + @Synchronized + fun consume(): Boolean { + val armedAt = armedAtMillis ?: return false + armedAtMillis = null + return nowMillis() - armedAt <= CONSENT_LIFETIME_MILLIS + } + + @Synchronized + fun cancel() { + armedAtMillis = null + } + + 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..d60b0f019 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendCardIssuer.kt @@ -0,0 +1,93 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.flatMap +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 + +class FriendCardReceiver( + private val store: FriendStore, +) { + fun receive( + invitation: String, + displayName: String, + authenticatedMinecraftUuid: UUID?, + now: Instant = Instant.now(), + ): Either = + store.accept(invitation, displayName, now).flatMap { friend -> + store.updatePermissions( + friend.peerId, + friend.permissions.copy( + canJoinAutomatically = true, + ), + ) + }.flatMap { friend -> + authenticatedMinecraftUuid?.let { minecraftUuid -> + store.linkMinecraftIdentity( + friend.peerId, + minecraftUuid, + ) + } ?: Either.Right(friend) + } +} + +class FriendCardIssuer( + private val dataDirectory: Path, + private val connectAddress: suspend () -> String?, +) { + suspend fun issue( + now: Instant = Instant.now(), + ): Either = + Either.catch { + val access = ShareAccessIdentityStore( + dataDirectory, + ).currentOrCreate() + DirectP2pNode( + dataDirectory.resolve(IDENTITY_FILE_NAME), + ).use { node -> + val payload = ShareInvitePayload( + wireVersion = ShareInviteCodec.WIRE_VERSION, + shareId = access.shareId, + expiresAtEpochMillis = now + .plusSeconds(CARD_LIFETIME_SECONDS) + .toEpochMilli(), + connectAddress = connectAddress(), + peerId = node.peerId(), + internetDirectEnabled = false, + directCandidates = emptyList(), + capability = access.capability, + ) + val publicKey = node.publicKey() + val unsigned = ShareInviteCodec.unsignedBytes( + payload, + publicKey, + ) + ShareInviteCodec.encode( + SignedShareInvite( + payload = payload, + publicKey = publicKey, + signature = node.sign(unsigned), + ), + ) + } + }.mapLeft { + FriendCardIssueFailure + } + + private companion object { + private const val IDENTITY_FILE_NAME = + "share-libp2p-identity.key" + private const val CARD_LIFETIME_SECONDS = 24 * 60 * 60L + } +} 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..c82545422 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitor.kt @@ -0,0 +1,88 @@ +package com.minekube.connect.share.fabric + +import arrow.fx.coroutines.parMap +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class RemoteFriendPresence( + val peerId: String, + val displayName: String, + val online: Boolean, + val description: String? = null, + val notifyWhenOnline: Boolean, +) + +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 probe: FriendStatusProbe, +) { + constructor( + store: FriendStore, + probe: FriendStatusProbe = MinecraftStatusProbe(), + ) : this( + friends = store::all, + probe = probe, + ) + + private val mutableState = + MutableStateFlow>(emptyMap()) + + val state: StateFlow> = + mutableState.asStateFlow() + + suspend fun refresh() { + val saved = runCatching(friends) + .getOrDefault(emptyList()) + .take(MAX_PROBED_FRIENDS) + val results = saved.parMap( + context = Dispatchers.IO, + concurrency = MAX_CONCURRENT_PROBES, + ) { friend -> + val result = friend.connectAddress?.let { + probe.probe(it) + } + val presence = result?.getOrNull() + friend.peerId to RemoteFriendPresence( + peerId = friend.peerId, + displayName = friend.displayName, + online = presence != null, + description = presence?.description, + notifyWhenOnline = + friend.permissions.notifyWhenOnline, + ) + } + mutableState.value = results.toMap() + } + + companion object { + internal fun testing( + friends: () -> List, + probe: FriendStatusProbe, + ) = FriendPresenceMonitor(friends, probe) + + 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/MinecraftStatusProbe.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt new file mode 100644 index 000000000..522b18fba --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/MinecraftStatusProbe.kt @@ -0,0 +1,205 @@ +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 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, +) + +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/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt new file mode 100644 index 000000000..7da48e29d --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt @@ -0,0 +1,163 @@ +package com.minekube.connect.share.fabric.ui + +import arrow.core.Either +import arrow.core.left +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.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.SavedFriend +import com.minekube.connect.tunnel.p2p.DirectP2pAuthMode +import java.time.Instant +import java.util.Base64 +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 onlineViaLan: Boolean = false, + val onlineViaConnect: Boolean = false, + val worldName: String? = null, +) + +data class FriendsUiState( + val friends: List = emptyList(), + val safeMessage: String? = null, +) + +class FriendsViewModel( + private val store: FriendStore, +) { + private var discovered: List = emptyList() + private var remotePresence: Map = emptyMap() + private val mutableState = MutableStateFlow(loadInitialState()) + + val state: StateFlow = mutableState.asStateFlow() + + fun accept( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): Boolean = + store.accept(invitationUri, displayName, now).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + false + }, + ifRight = { + refresh() + true + }, + ) + + fun rename(peerId: String, displayName: String) { + store.rename(peerId, displayName).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + refresh() + }, + ) + } + + fun updatePermissions( + peerId: String, + permissions: FriendPermissions, + ) { + store.updatePermissions(peerId, permissions).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { + refresh() + }, + ) + } + + fun remove(peerId: String) { + if (store.remove(peerId)) { + refresh() + } + } + + fun updatePresence(discovered: List) { + this.discovered = discovered + refresh() + } + + fun updateRemotePresence( + presence: Map, + ) { + remotePresence = presence + refresh() + } + + suspend fun join( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ): Either { + val friend = savedFriend(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.join(friend, authMode) + } + + internal fun savedFriend(peerId: String): SavedFriend? = + runCatching { + store.all().firstOrNull { it.peerId == peerId } + }.getOrNull() + + private fun refresh() { + mutableState.value = try { + FriendsUiState(friends = store.all().map { it.summary() }) + } catch (_: Exception) { + mutableState.value.copy( + safeMessage = FRIENDS_LOAD_FAILURE, + ) + } + } + + private fun loadInitialState(): FriendsUiState = try { + FriendsUiState(friends = store.all().map { it.summary() }) + } catch (_: Exception) { + FriendsUiState(safeMessage = FRIENDS_LOAD_FAILURE) + } + + private fun update(transform: FriendsUiState.() -> FriendsUiState) { + mutableState.value = mutableState.value.transform() + } + + private fun SavedFriend.summary(): FriendSummary { + val presence = discovered.firstOrNull { + val invitation = it.invitation + invitation.payload.peerId == peerId && + invitation.payload.shareId == shareId && + Base64.getEncoder().encodeToString(invitation.publicKey) == + publicKeyBase64 + } + val remote = remotePresence[peerId] + ?.takeIf { it.online } + return FriendSummary( + peerId = peerId, + displayName = displayName, + connectAvailable = connectAddress != null, + permissions = permissions, + onlineViaLan = presence != null, + onlineViaConnect = remote != null, + worldName = presence?.displayName ?: remote?.description, + ) + } + + private companion object { + const val FRIENDS_LOAD_FAILURE = + "Saved Connect Share friends could not be loaded" + } +} 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..5802dee98 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ApprovedJoinTrackerTest.kt @@ -0,0 +1,71 @@ +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 java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ApprovedJoinTrackerTest { + private var nowMillis = 1_000L + private val tracker = ApprovedJoinTracker { nowMillis } + + @Test + fun `approved authenticated identity can be consumed once`() { + tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + + assertEquals(true, tracker.hasProof("Robin", PLAYER_UUID)) + assertEquals( + PLAYER_UUID, + tracker.consume("Robin", PLAYER_UUID) + ?.authenticatedMinecraftUuid, + ) + assertNull(tracker.consume("Robin", PLAYER_UUID)) + } + + @Test + fun `approved offline identity proves pairing without trusting its uuid`() { + tracker.record(OFFLINE, AdmissionAnswer.ALLOW) + + val proof = tracker.consume("Robin", PLAYER_UUID) + + 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)) + } + + @Test + fun `authentication proof expires before an unrelated later join`() { + tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + nowMillis += 121_000 + + assertNull(tracker.consume("Robin", PLAYER_UUID)) + } + + 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/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt index 885a75a03..7324fef45 100644 --- 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 @@ -26,7 +26,12 @@ class FabricSessionAdmissionGateTest { @Test fun `Connect authenticated profile waits for host approval`() = runTest { val admission = admission() - val gate = FabricSessionAdmissionGate(admission, backgroundScope) + val approvedJoins = ApprovedJoinTracker() + val gate = FabricSessionAdmissionGate( + admission, + backgroundScope, + approvedJoins, + ) val result = gate.request(proposal(passthrough = false)).toCompletableFuture() runCurrent() @@ -39,6 +44,11 @@ class FabricSessionAdmissionGateTest { admission.answer(pending.requestId, allow = true) runCurrent() assertTrue(result.getNow(null).isAllowed) + assertEquals( + PLAYER_UUID, + approvedJoins.consume("Alex", PLAYER_UUID) + ?.authenticatedMinecraftUuid, + ) } @Test @@ -112,13 +122,18 @@ class FabricSessionAdmissionGateTest { @Test fun `local login maps authenticated and offline identities separately`() = runTest { val admission = admission() - val local = FabricLocalLoginAdmission(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() @@ -126,8 +141,17 @@ class FabricSessionAdmissionGateTest { 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()) + assertEquals( + PLAYER_UUID, + approvedJoins.consume("Alex", PLAYER_UUID) + ?.authenticatedMinecraftUuid, + ) val offline = async { local.request( @@ -135,6 +159,7 @@ class FabricSessionAdmissionGateTest { uuid = PLAYER_UUID, connectionId = "connection-offline", minecraftAuthenticated = false, + directPeerId = "12D3KooWOffline", ) } runCurrent() @@ -143,6 +168,7 @@ class FabricSessionAdmissionGateTest { ) 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()) } 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 index 7905adc98..3b128beb5 100644 --- 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 @@ -5,16 +5,19 @@ 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.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -22,8 +25,25 @@ 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 { @@ -96,6 +116,59 @@ class FabricShareBrowserTest { 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 ignores LAN metadata signed by a different identity`() = runTest { + val node = FakeGuestNode() + val browser = browser(node) + 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, + ) + + assertIs>(result) + assertTrue(node.openedAddresses.isEmpty()) + browser.close() + } + @Test fun `pasted invitation ignores discovery with a different peer`() = runTest { val node = FakeGuestNode() @@ -225,6 +298,22 @@ class FabricShareBrowserTest { ) } + 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, + displayName = "Robin", + ) + } + private fun lanAddress(peerId: String) = "/ip4/192.168.1.20/tcp/4001/p2p/$peerId" @@ -237,6 +326,8 @@ class FabricShareBrowserTest { private var listener: DirectP2pDiscoveryListener? = null val openedAddresses = mutableListOf() + override fun peerId(): String = "12D3KooWGuest" + override fun startDiscovery(listener: DirectP2pDiscoveryListener) { this.listener = listener } 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..e9194b659 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardExchangeConsentTest.kt @@ -0,0 +1,62 @@ +package com.minekube.connect.share.fabric + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FriendCardExchangeConsentTest { + private var nowMillis = 1_000L + private val consent = FriendCardExchangeConsent { nowMillis } + + @Test + fun `armed Share join allows exactly one reciprocal card request`() { + consent.arm() + + assertTrue(consent.consume()) + assertFalse(consent.consume()) + } + + @Test + fun `stale Share join cannot leak a card to a later server`() { + consent.arm() + nowMillis += 121_000 + + assertFalse(consent.consume()) + } + + @Test + fun `cancel removes pending consent`() { + consent.arm() + consent.cancel() + + assertFalse(consent.consume()) + } + + @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, + ), + ) + } +} 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..5aea63f9b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt @@ -0,0 +1,115 @@ +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" }, + ) + + 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, + ) + 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, + 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 `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, + ) + } + + private companion object { + val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") + } +} 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..64fe60cdd --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPresenceMonitorTest.kt @@ -0,0 +1,92 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +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.assertTrue +import kotlinx.coroutines.test.runTest + +class FriendPresenceMonitorTest { + @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) }, + probe = FriendStatusProbe { address -> + if (address.startsWith("online")) { + Either.Right(ServerPresence("Robin's World")) + } else { + Either.Left(StatusProbeError.EndpointOffline) + } + }, + ) + + 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 `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/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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt new file mode 100644 index 000000000..b86d2ca8b --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt @@ -0,0 +1,250 @@ +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.fabric.DiscoveredLanShare +import com.minekube.connect.share.fabric.FabricGuestDirectNode +import com.minekube.connect.share.fabric.FabricShareBrowser +import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.fabric.RemoteFriendPresence +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 com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendPermissions +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.assertTrue +import kotlin.test.assertIs +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 `accepting one link exposes a safe saved friend summary`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + assertTrue(viewModel.accept(signedLink(), "Robin", NOW)) + + val friend = viewModel.state.value.friends.single() + assertEquals(PEER_ID, friend.peerId) + assertEquals("Robin", friend.displayName) + assertTrue(friend.connectAvailable) + assertTrue(friend.permissions.notifyWhenOnline) + assertFalse(viewModel.state.value.toString().contains(CAPABILITY)) + assertEquals(null, viewModel.state.value.safeMessage) + } + + @Test + fun `invalid friend link stays on the add flow with a useful message`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + + val accepted = viewModel.accept( + "minekube://share/not-a-valid-link", + "Robin", + NOW, + ) + + assertFalse(accepted) + assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(viewModel.state.value.safeMessage?.isNotBlank() == true) + } + + @Test + fun `saved friend can be renamed configured and removed`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(signedLink(), "Robin", NOW) + + 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) + + viewModel.remove(PEER_ID) + + 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 viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(link, "Robin", NOW) + + viewModel.updatePresence( + listOf( + DiscoveredLanShare( + displayName = "Robin's New World", + invitationUri = link, + invitation = invitation, + lanAddress = + "/ip4/192.168.1.25/tcp/4001/p2p/$PEER_ID", + ), + ), + ) + + val online = viewModel.state.value.friends.single() + assertTrue(online.onlineViaLan) + assertEquals("Robin's New World", online.worldName) + + viewModel.updatePresence(emptyList()) + + assertFalse(viewModel.state.value.friends.single().onlineViaLan) + } + + @Test + fun `Connect presence marks a saved friend online across networks`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(signedLink(), "Robin", NOW) + + viewModel.updateRemotePresence( + mapOf( + PEER_ID to RemoteFriendPresence( + peerId = PEER_ID, + displayName = "Robin", + online = true, + description = "Robin's Remote World", + notifyWhenOnline = true, + ), + ), + ) + + val online = viewModel.state.value.friends.single() + assertTrue(online.onlineViaConnect) + assertEquals("Robin's Remote World", online.worldName) + } + + @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 viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.accept(link, "Robin", NOW) + 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() + } + + private fun signedLink(): 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, + ) + 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" + } +} From 23d6906c2dcfface4d6675043bcef277a36328d0 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 02:32:16 +0200 Subject: [PATCH 124/188] fix(share): repair friend removal and link access --- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 134 ++++++++++++++---- .../assets/connect-share/lang/de_de.json | 6 + .../assets/connect-share/lang/en_us.json | 6 + .../v1_21_11/Fabric12111ArtifactTest.kt | 23 +++ .../share/fabric/v26_2/ShareJoinScreen.kt | 134 ++++++++++++++---- .../assets/connect-share/lang/de_de.json | 6 + .../assets/connect-share/lang/en_us.json | 6 + .../fabric/v26_2/Fabric262ArtifactTest.kt | 25 ++++ .../share/fabric/ui/FriendsViewModel.kt | 20 ++- .../share/fabric/ui/FriendsViewModelTest.kt | 4 +- 10 files changed, 308 insertions(+), 56 deletions(-) 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 index be999fb51..365b9afe7 100644 --- 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 @@ -10,17 +10,18 @@ import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions 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.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.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -52,6 +53,8 @@ class ShareJoinScreen( private var joiningPeerId: String? = null private var reciprocalPairing = false private var transferred = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE override fun init() { if (scope == null) { @@ -88,6 +91,11 @@ class ShareJoinScreen( } override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } when (mode) { Mode.FRIENDS -> minecraft.setScreen(parent) Mode.ADD, @@ -159,11 +167,26 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 54) + centered(Component.literal(message), height - 76) .setMaxWidth(CONTENT_WIDTH), ) } } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.add"), @@ -171,11 +194,11 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) } @@ -316,6 +339,10 @@ class ShareJoinScreen( rebuildWidgets() return } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } addRenderableWidget( centered( Component.translatable( @@ -396,25 +423,8 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.remove"), ) { - minecraft.setScreen( - ConfirmScreen( - { confirmed -> - if (confirmed) { - friends.remove(friend.peerId) - mode = Mode.FRIENDS - selectedPeerId = null - } - minecraft.setScreen(this) - }, - Component.translatable( - "connect_share.friends.remove_confirm.title", - friend.displayName, - ), - Component.translatable( - "connect_share.friends.remove_confirm.message", - ), - ), - ) + removeConfirmation = true + rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -425,6 +435,70 @@ class ShareJoinScreen( refresh() } + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + friends.remove(friend.peerId) + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + nameValue = "" + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 5, height - 28, 150, 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 @@ -518,7 +592,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() - primaryButton?.active = !joining && + primaryButton?.active = + !joining && friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -595,6 +670,15 @@ class ShareJoinScreen( MANAGE, } + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.copy_my_link"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 const val MAX_VISIBLE_FRIENDS = 5 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 index 1653c6127..bf89595ad 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Beitreten", "connect_share.friends.title": "Freunde", "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -62,6 +67,7 @@ "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.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", 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 index 7abb70291..bde5bf82b 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends yet. Add a friend's link once; their future worlds appear here.", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -62,6 +67,7 @@ "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.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", 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 index bba0ddf50..ad58bb5d9 100644 --- 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 @@ -34,6 +34,29 @@ class Fabric12111ArtifactTest { "\"connect_share.status.copy_invitation\": " + "\"Copy friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val screen = jar.getJarEntry( + "com/minekube/connect/share/fabric/v1_21_11/" + + "ShareJoinScreen.class", + ) + assertNotNull(screen) + val bytecode = jar.getInputStream(screen).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + + assertFalse("net/minecraft/class_410" in bytecode) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) } } 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 index b0ee9b699..ffdc17882 100644 --- 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 @@ -10,17 +10,18 @@ import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions 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.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.EditBox 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.ConfirmScreen import net.minecraft.client.gui.screens.Screen import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress @@ -52,6 +53,8 @@ class ShareJoinScreen( private var joiningPeerId: String? = null private var reciprocalPairing = false private var transferred = false + private var removeConfirmation = false + private var friendLinkState = FriendLinkState.IDLE override fun init() { if (scope == null) { @@ -88,6 +91,11 @@ class ShareJoinScreen( } override fun onClose() { + if (mode == Mode.MANAGE && removeConfirmation) { + removeConfirmation = false + rebuildWidgets() + return + } when (mode) { Mode.FRIENDS -> minecraft.gui.setScreen(parent) Mode.ADD, @@ -159,11 +167,26 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 54) + centered(Component.literal(message), height - 76) .setMaxWidth(CONTENT_WIDTH), ) } } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.add"), @@ -171,11 +194,11 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) .build(), ) } @@ -316,6 +339,10 @@ class ShareJoinScreen( rebuildWidgets() return } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } addRenderableWidget( centered( Component.translatable( @@ -396,25 +423,8 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.remove"), ) { - minecraft.gui.setScreen( - ConfirmScreen( - { confirmed -> - if (confirmed) { - friends.remove(friend.peerId) - mode = Mode.FRIENDS - selectedPeerId = null - } - minecraft.gui.setScreen(this) - }, - Component.translatable( - "connect_share.friends.remove_confirm.title", - friend.displayName, - ), - Component.translatable( - "connect_share.friends.remove_confirm.message", - ), - ), - ) + removeConfirmation = true + rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -425,6 +435,70 @@ class ShareJoinScreen( refresh() } + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ).setMaxWidth(CONTENT_WIDTH), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.remove_confirm.confirm", + ), + ) { + friends.remove(friend.peerId) + removeConfirmation = false + mode = Mode.FRIENDS + selectedPeerId = null + nameValue = "" + rebuildWidgets() + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 5, height - 28, 150, 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 @@ -517,7 +591,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() - primaryButton?.active = !joining && + primaryButton?.active = + !joining && friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -594,6 +669,15 @@ class ShareJoinScreen( MANAGE, } + private enum class FriendLinkState( + val translationKey: String, + ) { + IDLE("connect_share.friends.copy_my_link"), + COPYING("connect_share.friends.copying_my_link"), + COPIED("connect_share.friends.my_link_copied"), + FAILED("connect_share.friends.copy_my_link_failed"), + } + private companion object { const val MAX_INVITATION_LENGTH = 32_768 const val MAX_VISIBLE_FRIENDS = 5 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 index 1653c6127..bf89595ad 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Beitreten", "connect_share.friends.title": "Freunde", "connect_share.friends.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", + "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -62,6 +67,7 @@ "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.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", 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 index 7abb70291..bde5bf82b 100644 --- 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 @@ -45,6 +45,11 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends yet. Add a friend's link once; their future worlds appear here.", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -62,6 +67,7 @@ "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.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", 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 index 3abd08604..8b9b2b468 100644 --- 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 @@ -34,6 +34,31 @@ class Fabric262ArtifactTest { "\"connect_share.status.copy_invitation\": " + "\"Copy friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.copy_my_link\": " + + "\"Copy my friend link\"" in language, + ) + } + } + + @Test + fun `friend removal confirmation stays inside the friends screen`() { + JarFile(artifact().toFile()).use { jar -> + val screen = jar.getJarEntry( + "com/minekube/connect/share/fabric/v26_2/" + + "ShareJoinScreen.class", + ) + assertNotNull(screen) + val bytecode = jar.getInputStream(screen).use { + it.readBytes().toString(Charsets.ISO_8859_1) + } + + assertFalse( + "net/minecraft/client/gui/screens/ConfirmScreen" in bytecode, + ) + assertTrue( + "connect_share.friends.remove_confirm.confirm" in bytecode, + ) } } 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 index 7da48e29d..7de59d74e 100644 --- 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 @@ -82,11 +82,19 @@ class FriendsViewModel( ) } - fun remove(peerId: String) { - if (store.remove(peerId)) { - refresh() - } - } + fun remove(peerId: String): Boolean = + Either.catch { + store.remove(peerId) + }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_REMOVE_FAILURE) } + false + }, + ifRight = { removed -> + refresh() + removed + }, + ) fun updatePresence(discovered: List) { this.discovered = discovered @@ -159,5 +167,7 @@ class FriendsViewModel( private companion object { const val FRIENDS_LOAD_FAILURE = "Saved Connect Share friends could not be loaded" + const val FRIEND_REMOVE_FAILURE = + "This Connect Share friend could not be removed" } } 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 index b86d2ca8b..80f572438 100644 --- 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 @@ -84,9 +84,11 @@ class FriendsViewModelTest { assertFalse(managed.permissions.notifyWhenOnline) assertTrue(managed.permissions.canJoinAutomatically) - viewModel.remove(PEER_ID) + assertTrue(viewModel.remove(PEER_ID)) assertTrue(viewModel.state.value.friends.isEmpty()) + assertTrue(FriendStore(tempDir).all().isEmpty()) + assertFalse(viewModel.remove(PEER_ID)) } @Test From 3b7687eb1c2fcc9314e00df43b7a21138d2300e9 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 02:54:12 +0200 Subject: [PATCH 125/188] feat(share): add mutual friend requests --- .../connect/share/friend/FriendStore.kt | 106 ++++++++++++-- .../connect/share/friend/FriendStoreTest.kt | 100 +++++++++++++ .../fabric/v1_21_11/FriendCardNetworking.kt | 9 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 135 +++++++++++++----- .../assets/connect-share/lang/de_de.json | 10 +- .../assets/connect-share/lang/en_us.json | 10 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 38 +++++ .../fabric/v26_2/FriendCardNetworking.kt | 9 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 135 +++++++++++++----- .../assets/connect-share/lang/de_de.json | 10 +- .../assets/connect-share/lang/en_us.json | 10 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 38 +++++ .../share/fabric/ConnectShareClient.kt | 6 +- .../share/fabric/FriendCardExchangeConsent.kt | 32 +++-- .../connect/share/fabric/FriendCardIssuer.kt | 5 + .../share/fabric/ui/FriendsViewModel.kt | 42 +++++- .../fabric/FriendCardExchangeConsentTest.kt | 22 +-- .../share/fabric/FriendCardIssuerTest.kt | 27 ++++ .../share/fabric/ui/FriendsViewModelTest.kt | 98 ++++++++++--- 19 files changed, 704 insertions(+), 138 deletions(-) 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 index bffa267aa..ea886d027 100644 --- 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 @@ -32,6 +32,11 @@ data class FriendPermissions( val canJoinAutomatically: Boolean = false, ) +enum class FriendRelationshipStatus { + PENDING_INCOMING, + CONFIRMED, +} + data class SavedFriend( val peerId: String, val publicKeyBase64: String, @@ -41,12 +46,15 @@ data class SavedFriend( 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, displayName=$displayName, " + - "minecraftUuid=$minecraftUuid, permissions=$permissions)" + "minecraftUuid=$minecraftUuid, permissions=$permissions, " + + "relationshipStatus=$relationshipStatus)" } sealed interface FriendStoreError { @@ -76,13 +84,59 @@ class FriendStore( private val directory: Path, ) { @Synchronized - fun all(): List = read() + fun all(): List = + read().filter { + it.relationshipStatus == FriendRelationshipStatus.CONFIRMED + } + + @Synchronized + fun pendingRequests(): List = + read().filter { + it.relationshipStatus == + FriendRelationshipStatus.PENDING_INCOMING + } @Synchronized fun accept( invitationUri: String, displayName: String, now: Instant = Instant.now(), + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + now = now, + ) + + @Synchronized + fun receiveRequest( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = + FriendRelationshipStatus.PENDING_INCOMING, + now = now, + ) + + @Synchronized + fun confirmPending( + peerId: String, + ): Either = update(peerId) { friend -> + friend.copy( + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + ) + } + + private fun storeInvitation( + invitationUri: String, + displayName: String, + relationshipStatus: FriendRelationshipStatus, + now: Instant, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) .mapLeft(FriendStoreError::InvalidInvitation) @@ -100,6 +154,13 @@ class FriendStore( 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, @@ -109,6 +170,7 @@ class FriendStore( displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = existing?.permissions ?: FriendPermissions(), + relationshipStatus = effectiveRelationshipStatus, ) write( current.filterNot { it.peerId == friend.peerId } + friend, @@ -222,6 +284,21 @@ class FriendStore( 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"), + canJoinAutomatically = + permissions.requiredBoolean("canJoinAutomatically"), + ) + val relationshipStatus = json + .optionalString("relationshipStatus") + ?.let(FriendRelationshipStatus::valueOf) + ?: legacyRelationshipStatus( + minecraftUuid = minecraftUuid, + permissions = parsedPermissions, + ) return SavedFriend( peerId = peerId, publicKeyBase64 = publicKey, @@ -230,12 +307,8 @@ class FriendStore( connectAddress = connectAddress, displayName = displayName, minecraftUuid = minecraftUuid, - permissions = FriendPermissions( - notifyWhenOnline = permissions.requiredBoolean("notifyWhenOnline"), - canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), - canJoinAutomatically = - permissions.requiredBoolean("canJoinAutomatically"), - ), + permissions = parsedPermissions, + relationshipStatus = relationshipStatus, ) } @@ -258,6 +331,10 @@ class FriendStore( friend.minecraftUuid?.let { addProperty("minecraftUuid", it.toString()) } + addProperty( + "relationshipStatus", + friend.relationshipStatus.name, + ) add( "permissions", JsonObject().apply { @@ -346,6 +423,19 @@ class FriendStore( 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_INCOMING + } + private fun isValidCapability(value: String): Boolean = value.length in 16..512 && value.none(Char::isWhitespace) 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 index 106ea2738..d5dc67bf5 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,90 @@ class FriendStoreTest { @TempDir lateinit var tempDir: Path + @Test + fun `receiving a signed link stores only a pending request`() { + val store = FriendStore(tempDir) + + val request = assertIs>( + store.receiveRequest(signedLink(), "Robin", NOW), + ).value + + assertEquals( + FriendRelationshipStatus.PENDING_INCOMING, + request.relationshipStatus, + ) + assertTrue(store.all().isEmpty()) + assertEquals( + listOf(request), + FriendStore(tempDir).pendingRequests(), + ) + } + + @Test + fun `confirming a pending request promotes it across restarts`() { + val store = FriendStore(tempDir) + store.receiveRequest(signedLink(), "Robin", NOW) + + val confirmed = assertIs>( + store.confirmPending(PEER_ID), + ).value + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + confirmed.relationshipStatus, + ) + assertEquals( + listOf(confirmed), + FriendStore(tempDir).all(), + ) + assertTrue(FriendStore(tempDir).pendingRequests().isEmpty()) + } + + @Test + fun `receiving the same link never demotes a confirmed friend`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + + val received = assertIs>( + store.receiveRequest(signedLink(), "Robin", NOW), + ).value + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + received.relationshipStatus, + ) + assertEquals(PEER_ID, store.all().single().peerId) + assertTrue(store.pendingRequests().isEmpty()) + } + + @Test + fun `legacy unverified relationships migrate to pending`() { + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + stripRelationshipStatus() + + val migrated = FriendStore(tempDir) + + assertTrue(migrated.all().isEmpty()) + assertEquals(PEER_ID, migrated.pendingRequests().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.pendingRequests().isEmpty()) + } + @Test fun `accepting one signed link saves a friend across restarts`() { val link = signedLink() @@ -36,6 +121,10 @@ class FriendStoreTest { 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) @@ -146,6 +235,17 @@ class FriendStoreTest { ) } + 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 companion object { val NOW: Instant = Instant.parse("2026-07-31T00:00:00Z") val SHARE_ID: UUID = 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 index a781e05ba..ad8d412c3 100644 --- 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 @@ -67,9 +67,9 @@ object FriendCardNetworking { ClientPlayNetworking.registerGlobalReceiver( FriendCardRequestPayload.TYPE, ) { _, context -> - if (!ConnectShareClient.consumeFriendCardExchangeConsent()) { - return@registerGlobalReceiver - } + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver val client = context.client() scope.launch(Dispatchers.IO) { issuer.issue().getOrNull()?.let { invitation -> @@ -83,6 +83,9 @@ object FriendCardNetworking { ClientPlayNetworking.send( FriendCardPayload(invitation), ) + scope.launch(Dispatchers.IO) { + receiver.confirmPending(exchange.peerId) + } } } } 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 index 365b9afe7..06cd8441c 100644 --- 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 @@ -132,36 +132,74 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) - val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) - if (saved.isEmpty()) { + val state = friends.state.value + val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val saved = state.friends.take( + MAX_VISIBLE_RELATIONSHIPS - pending.size, + ) + if (pending.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), 82, ).setMaxWidth(CONTENT_WIDTH), ) - } else { - saved.forEachIndexed { index, friend -> - val y = 58 + index * 26 - addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null - rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), - ) - } + } + pending.forEachIndexed { index, request -> + val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.pending_request", + request.displayName, + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.accept_request", + ), + ) { + joinPending(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.decline_request", + ), + ) { + friends.remove(request.peerId) + rebuildWidgets() + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + saved.forEachIndexed { index, friend -> + val y = 58 + (pending.size + index) * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) } safeMessage().let { message -> @@ -302,12 +340,11 @@ class ShareJoinScreen( } primaryButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.save"), + Component.translatable( + "connect_share.friends.save_request", + ), ) { - if (friends.accept(invitationValue, nameValue)) { - scope?.launch { - remotePresence.refresh() - } + if (friends.receiveRequest(invitationValue, nameValue)) { invitationValue = "" nameValue = "" mode = Mode.FRIENDS @@ -518,6 +555,25 @@ class ShareJoinScreen( } } + private fun joinPending(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.joinPending( + peerId = peerId, + browser = browser, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -564,21 +620,30 @@ class ShareJoinScreen( } else { browser.close() } - val joiningFriend = friends.state.value.friends.firstOrNull { + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val pendingRequest = state.pendingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( - joiningFriend?.displayName ?: "Connect Share", + joiningFriend?.displayName + ?: pendingRequest?.displayName + ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds, + joiningFriend?.permissions?.canSeeMyWorlds + ?: (pendingRequest != null), ) - if (exchangeFriendCard) { - ConnectShareClient.armFriendCardExchange() + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) } ConnectScreen.startConnecting( parent, @@ -681,7 +746,7 @@ class ShareJoinScreen( private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_FRIENDS = 5 + const val MAX_VISIBLE_RELATIONSHIPS = 5 const val CONTENT_WIDTH = 310 } } 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 index bf89595ad..43091293b 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.empty": "Noch keine Freunde oder Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", + "connect_share.friends.pending_request": "Anfrage von %s", + "connect_share.friends.accept_request": "Annehmen", + "connect_share.friends.decline_request": "Ablehnen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", "connect_share.friends.name": "Name des Freundes", "connect_share.friends.name_hint": "Wie du die Person kennst", "connect_share.friends.save": "Freund speichern", + "connect_share.friends.save_request": "Anfrage speichern", "connect_share.friends.join_once": "Einmal beitreten", "connect_share.friends.manage_title": "%s verwalten", "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", 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 index bde5bf82b..aa60a7bf6 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.empty": "No friends or requests yet. Add a friend's link to send a request.", + "connect_share.friends.pending_request": "Request from %s", + "connect_share.friends.accept_request": "Accept", + "connect_share.friends.decline_request": "Decline", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", "connect_share.friends.name": "Friend name", "connect_share.friends.name_hint": "How you know them", "connect_share.friends.save": "Save friend", + "connect_share.friends.save_request": "Save request", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", 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 index ad58bb5d9..5e878dc3d 100644 --- 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 @@ -38,6 +38,22 @@ class Fabric12111ArtifactTest { "\"connect_share.friends.copy_my_link\": " + "\"Copy my friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.save_request\": " + + "\"Save request\"" in language, + ) + assertTrue( + "\"connect_share.friends.pending_request\": " + + "\"Request from %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.accept_request\": \"Accept\"" in + language, + ) + assertTrue( + "\"connect_share.friends.decline_request\": \"Decline\"" in + language, + ) } } @@ -57,6 +73,28 @@ class Fabric12111ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) + assertTrue("receiveRequest" in bytecode) + assertTrue("joinPending" in bytecode) + } + } + + @Test + fun `approved card exchange promotes a pending 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("confirmPending" in bytecode) } } 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 index f5b6ef4e9..a8702303c 100644 --- 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 @@ -67,9 +67,9 @@ object FriendCardNetworking { ClientPlayNetworking.registerGlobalReceiver( FriendCardRequestPayload.TYPE, ) { _, context -> - if (!ConnectShareClient.consumeFriendCardExchangeConsent()) { - return@registerGlobalReceiver - } + val exchange = + ConnectShareClient.consumeFriendCardExchangeConsent() + ?: return@registerGlobalReceiver val client = context.client() scope.launch(Dispatchers.IO) { issuer.issue().getOrNull()?.let { invitation -> @@ -83,6 +83,9 @@ object FriendCardNetworking { ClientPlayNetworking.send( FriendCardPayload(invitation), ) + scope.launch(Dispatchers.IO) { + receiver.confirmPending(exchange.peerId) + } } } } 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 index ffdc17882..8382a9f5c 100644 --- 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 @@ -132,36 +132,74 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) - val saved = friends.state.value.friends.take(MAX_VISIBLE_FRIENDS) - if (saved.isEmpty()) { + val state = friends.state.value + val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val saved = state.friends.take( + MAX_VISIBLE_RELATIONSHIPS - pending.size, + ) + if (pending.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), 82, ).setMaxWidth(CONTENT_WIDTH), ) - } else { - saved.forEachIndexed { index, friend -> - val y = 58 + index * 26 - addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null - rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), - ) - } + } + pending.forEachIndexed { index, request -> + val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.pending_request", + request.displayName, + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.accept_request", + ), + ) { + joinPending(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.decline_request", + ), + ) { + friends.remove(request.peerId) + rebuildWidgets() + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + saved.forEachIndexed { index, friend -> + val y = 58 + (pending.size + index) * 26 + addRenderableWidget( + Button.builder(friendLabel(friend)) { + joinSaved(friend.peerId) + }.bounds(width / 2 - 155, y, 242, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.manage", + ), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) } safeMessage().let { message -> @@ -302,12 +340,11 @@ class ShareJoinScreen( } primaryButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.save"), + Component.translatable( + "connect_share.friends.save_request", + ), ) { - if (friends.accept(invitationValue, nameValue)) { - scope?.launch { - remotePresence.refresh() - } + if (friends.receiveRequest(invitationValue, nameValue)) { invitationValue = "" nameValue = "" mode = Mode.FRIENDS @@ -518,6 +555,25 @@ class ShareJoinScreen( } } + private fun joinPending(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = true + safeMessage = null + refresh() + scope?.launch { + friends.joinPending( + peerId = peerId, + browser = browser, + authMode = authMode(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -563,21 +619,30 @@ class ShareJoinScreen( } else { browser.close() } - val joiningFriend = friends.state.value.friends.firstOrNull { + val state = friends.state.value + val joiningFriend = state.friends.firstOrNull { + it.peerId == joiningPeerId + } + val pendingRequest = state.pendingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( - joiningFriend?.displayName ?: "Connect Share", + joiningFriend?.displayName + ?: pendingRequest?.displayName + ?: "Connect Share", address.toString(), ServerData.Type.OTHER, ) val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds, + joiningFriend?.permissions?.canSeeMyWorlds + ?: (pendingRequest != null), ) - if (exchangeFriendCard) { - ConnectShareClient.armFriendCardExchange() + if (exchangeFriendCard && joiningPeerId != null) { + ConnectShareClient.armFriendCardExchange( + checkNotNull(joiningPeerId), + ) } ConnectScreen.startConnecting( parent, @@ -680,7 +745,7 @@ class ShareJoinScreen( private companion object { const val MAX_INVITATION_LENGTH = 32_768 - const val MAX_VISIBLE_FRIENDS = 5 + const val MAX_VISIBLE_RELATIONSHIPS = 5 const val CONTENT_WIDTH = 310 } } 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 index bf89595ad..43091293b 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Gespeicherte Freunde erscheinen hier, sobald ihre Welt bereit ist.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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. Füge den Link eines Freundes einmal hinzu; zukünftige Welten erscheinen hier.", + "connect_share.friends.empty": "Noch keine Freunde oder Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", + "connect_share.friends.pending_request": "Anfrage von %s", + "connect_share.friends.accept_request": "Annehmen", + "connect_share.friends.decline_request": "Ablehnen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Das musst du nur einmal tun.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", "connect_share.friends.name": "Name des Freundes", "connect_share.friends.name_hint": "Wie du die Person kennst", "connect_share.friends.save": "Freund speichern", + "connect_share.friends.save_request": "Anfrage speichern", "connect_share.friends.join_once": "Einmal beitreten", "connect_share.friends.manage_title": "%s verwalten", "connect_share.friends.notify": "Benachrichtigen, wenn die Welt bereit ist", 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 index bde5bf82b..aa60a7bf6 100644 --- 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 @@ -44,19 +44,23 @@ "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.description": "Saved friends appear here whenever their world is ready.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends yet. Add a friend's link once; their future worlds appear here.", + "connect_share.friends.empty": "No friends or requests yet. Add a friend's link to send a request.", + "connect_share.friends.pending_request": "Request from %s", + "connect_share.friends.accept_request": "Accept", + "connect_share.friends.decline_request": "Decline", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. You only need to do this once.", + "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", "connect_share.friends.name": "Friend name", "connect_share.friends.name_hint": "How you know them", "connect_share.friends.save": "Save friend", + "connect_share.friends.save_request": "Save request", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", 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 index 8b9b2b468..c0ff26576 100644 --- 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 @@ -38,6 +38,22 @@ class Fabric262ArtifactTest { "\"connect_share.friends.copy_my_link\": " + "\"Copy my friend link\"" in language, ) + assertTrue( + "\"connect_share.friends.save_request\": " + + "\"Save request\"" in language, + ) + assertTrue( + "\"connect_share.friends.pending_request\": " + + "\"Request from %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.accept_request\": \"Accept\"" in + language, + ) + assertTrue( + "\"connect_share.friends.decline_request\": \"Decline\"" in + language, + ) } } @@ -59,6 +75,28 @@ class Fabric262ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) + assertTrue("receiveRequest" in bytecode) + assertTrue("joinPending" in bytecode) + } + } + + @Test + fun `approved card exchange promotes a pending 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("confirmPending" in bytecode) } } 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 index 50412d4fd..b85069c23 100644 --- 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 @@ -77,12 +77,12 @@ object ConnectShareClient { checkNotNull(installation).friendCardIssuer @JvmStatic - fun armFriendCardExchange() { - friendCardConsent.arm() + fun armFriendCardExchange(peerId: String) { + friendCardConsent.arm(peerId) } @JvmStatic - fun consumeFriendCardExchangeConsent(): Boolean = + fun consumeFriendCardExchangeConsent(): FriendCardExchangeProof? = friendCardConsent.consume() @JvmStatic 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 index e49d3c605..8e9eb9da3 100644 --- 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 @@ -1,27 +1,43 @@ package com.minekube.connect.share.fabric +data class FriendCardExchangeProof( + val peerId: String, +) + class FriendCardExchangeConsent( private val nowMillis: () -> Long = System::currentTimeMillis, ) { - private var armedAtMillis: Long? = null + private var armed: TimedExchange? = null @Synchronized - fun arm() { - armedAtMillis = nowMillis() + fun arm(peerId: String) { + require(peerId.isNotBlank()) + armed = TimedExchange( + proof = FriendCardExchangeProof(peerId), + armedAtMillis = nowMillis(), + ) } @Synchronized - fun consume(): Boolean { - val armedAt = armedAtMillis ?: return false - armedAtMillis = null - return nowMillis() - armedAt <= CONSENT_LIFETIME_MILLIS + fun consume(): FriendCardExchangeProof? { + val exchange = armed ?: return null + armed = null + return exchange.proof.takeIf { + nowMillis() - exchange.armedAtMillis <= + CONSENT_LIFETIME_MILLIS + } } @Synchronized fun cancel() { - armedAtMillis = null + armed = null } + private data class TimedExchange( + val proof: FriendCardExchangeProof, + val armedAtMillis: Long, + ) + companion object { const val CONSENT_LIFETIME_MILLIS = 120_000L 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 index d60b0f019..661872f3c 100644 --- 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 @@ -19,6 +19,11 @@ data object FriendCardIssueFailure class FriendCardReceiver( private val store: FriendStore, ) { + fun confirmPending( + peerId: String, + ): Either = + store.confirmPending(peerId) + fun receive( invitation: String, displayName: String, 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 index 7de59d74e..943bdf180 100644 --- 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 @@ -27,8 +27,14 @@ data class FriendSummary( val worldName: String? = null, ) +data class PendingFriendSummary( + val peerId: String, + val displayName: String, +) + data class FriendsUiState( val friends: List = emptyList(), + val pendingRequests: List = emptyList(), val safeMessage: String? = null, ) @@ -41,12 +47,12 @@ class FriendsViewModel( val state: StateFlow = mutableState.asStateFlow() - fun accept( + fun receiveRequest( invitationUri: String, displayName: String, now: Instant = Instant.now(), ): Boolean = - store.accept(invitationUri, displayName, now).fold( + store.receiveRequest(invitationUri, displayName, now).fold( ifLeft = { failure -> update { copy(safeMessage = failure.safeMessage) } false @@ -118,14 +124,31 @@ class FriendsViewModel( return browser.join(friend, authMode) } + suspend fun joinPending( + peerId: String, + browser: FabricShareBrowser, + authMode: DirectP2pAuthMode, + ): Either { + val request = pendingRequest(peerId) + ?: return GuestJoinFailure.NoRoute.left() + return browser.join(request, authMode) + } + internal fun savedFriend(peerId: String): SavedFriend? = runCatching { store.all().firstOrNull { it.peerId == peerId } }.getOrNull() + internal fun pendingRequest(peerId: String): SavedFriend? = + runCatching { + store.pendingRequests().firstOrNull { + it.peerId == peerId + } + }.getOrNull() + private fun refresh() { mutableState.value = try { - FriendsUiState(friends = store.all().map { it.summary() }) + currentState() } catch (_: Exception) { mutableState.value.copy( safeMessage = FRIENDS_LOAD_FAILURE, @@ -134,11 +157,22 @@ class FriendsViewModel( } private fun loadInitialState(): FriendsUiState = try { - FriendsUiState(friends = store.all().map { it.summary() }) + currentState() } catch (_: Exception) { FriendsUiState(safeMessage = FRIENDS_LOAD_FAILURE) } + private fun currentState(): FriendsUiState = + FriendsUiState( + friends = store.all().map { it.summary() }, + pendingRequests = store.pendingRequests().map { + PendingFriendSummary( + peerId = it.peerId, + displayName = it.displayName, + ) + }, + ) + private fun update(transform: FriendsUiState.() -> FriendsUiState) { mutableState.value = mutableState.value.transform() } 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 index e9194b659..d177e8b90 100644 --- 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 @@ -1,7 +1,9 @@ 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 class FriendCardExchangeConsentTest { @@ -9,27 +11,27 @@ class FriendCardExchangeConsentTest { private val consent = FriendCardExchangeConsent { nowMillis } @Test - fun `armed Share join allows exactly one reciprocal card request`() { - consent.arm() + fun `armed Share join returns its peer exactly once`() { + consent.arm(PEER_ID) - assertTrue(consent.consume()) - assertFalse(consent.consume()) + assertEquals(PEER_ID, consent.consume()?.peerId) + assertNull(consent.consume()) } @Test fun `stale Share join cannot leak a card to a later server`() { - consent.arm() + consent.arm(PEER_ID) nowMillis += 121_000 - assertFalse(consent.consume()) + assertNull(consent.consume()) } @Test fun `cancel removes pending consent`() { - consent.arm() + consent.arm(PEER_ID) consent.cancel() - assertFalse(consent.consume()) + assertNull(consent.consume()) } @Test @@ -59,4 +61,8 @@ class FriendCardExchangeConsentTest { ), ) } + + 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 index 5aea63f9b..1955dc91e 100644 --- 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 @@ -89,6 +89,33 @@ class FriendCardIssuerTest { assertTrue(saved.permissions.canJoinAutomatically) } + @Test + fun `approved exchange promotes the accepter pending 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.receiveRequest(card, "Robin", NOW) + val receiver = FriendCardReceiver(store) + + val result = receiver.confirmPending(peerId) + + assertIs< + Either.Right< + com.minekube.connect.share.friend.SavedFriend, + > + >(result) + assertEquals(peerId, store.all().single().peerId) + assertTrue(store.pendingRequests().isEmpty()) + } + @Test fun `card issuer resolves the persisted endpoint asynchronously`() = runBlocking { 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 index 80f572438..1345927e1 100644 --- 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 @@ -8,14 +8,14 @@ import com.minekube.connect.share.fabric.FabricGuestDirectNode import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.GuestJoinTarget import com.minekube.connect.share.fabric.RemoteFriendPresence +import com.minekube.connect.share.friend.FriendPermissions +import com.minekube.connect.share.friend.FriendStore 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 com.minekube.connect.share.friend.FriendStore -import com.minekube.connect.share.friend.FriendPermissions import java.nio.file.Path import java.security.KeyPairGenerator import java.security.Signature @@ -24,8 +24,8 @@ import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue 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 @@ -35,25 +35,48 @@ class FriendsViewModelTest { lateinit var tempDir: Path @Test - fun `accepting one link exposes a safe saved friend summary`() { + fun `receiving one link exposes only a pending request`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - assertTrue(viewModel.accept(signedLink(), "Robin", NOW)) + assertTrue(viewModel.receiveRequest(signedLink(), "Robin", NOW)) - val friend = viewModel.state.value.friends.single() - assertEquals(PEER_ID, friend.peerId) - assertEquals("Robin", friend.displayName) - assertTrue(friend.connectAvailable) - assertTrue(friend.permissions.notifyWhenOnline) + val request = viewModel.state.value.pendingRequests.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 `pending request never exposes presence as a friend`() { + val viewModel = FriendsViewModel(FriendStore(tempDir)) + viewModel.receiveRequest(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.pendingRequests.single().peerId, + ) + } + @Test fun `invalid friend link stays on the add flow with a useful message`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - val accepted = viewModel.accept( + val accepted = viewModel.receiveRequest( "minekube://share/not-a-valid-link", "Robin", NOW, @@ -66,8 +89,9 @@ class FriendsViewModelTest { @Test fun `saved friend can be renamed configured and removed`() { - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(signedLink(), "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.rename(PEER_ID, "Robin from Discord") viewModel.updatePermissions( @@ -95,8 +119,9 @@ class FriendsViewModelTest { fun `matching discovery marks a saved friend world ready to join`() { val link = signedLink() val invitation = ShareInviteCodec.decode(link, NOW).getOrNull()!! - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(link, "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(link, "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.updatePresence( listOf( @@ -121,8 +146,9 @@ class FriendsViewModelTest { @Test fun `Connect presence marks a saved friend online across networks`() { - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(signedLink(), "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(signedLink(), "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.updateRemotePresence( mapOf( @@ -160,8 +186,9 @@ class FriendsViewModelTest { link, ), ) - val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.accept(link, "Robin", NOW) + val store = FriendStore(tempDir) + store.accept(link, "Robin", NOW) + val viewModel = FriendsViewModel(store) viewModel.updatePresence( listOf( DiscoveredLanShare( @@ -184,6 +211,39 @@ class FriendsViewModelTest { browser.close() } + @Test + fun `accepting a pending 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.receiveRequest(link, "Robin", NOW) + + val result = viewModel.joinPending( + peerId = PEER_ID, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + + assertIs>(result) + assertEquals(listOf(LAN_ADDRESS), node.openedAddresses) + assertTrue(viewModel.state.value.friends.isEmpty()) + browser.close() + } + private fun signedLink(): String { val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() val payload = ShareInvitePayload( From 90305a6c139ccdcdf7c4fb88364ef91088c3dd34 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 09:28:52 +0200 Subject: [PATCH 126/188] fix(share): send friend requests remotely --- .../connect/share/friend/FriendStore.kt | 25 ++++++--- .../connect/share/friend/FriendStoreTest.kt | 54 ++++++++++++++----- .../fabric/v1_21_11/FriendCardNetworking.kt | 2 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 46 +++++++++------- .../assets/connect-share/lang/de_de.json | 29 +++++----- .../assets/connect-share/lang/en_us.json | 33 ++++++------ .../v1_21_11/Fabric12111ArtifactTest.kt | 28 ++++++---- .../fabric/v26_2/FriendCardNetworking.kt | 2 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 46 +++++++++------- .../assets/connect-share/lang/de_de.json | 29 +++++----- .../assets/connect-share/lang/en_us.json | 33 ++++++------ .../fabric/v26_2/Fabric262ArtifactTest.kt | 28 ++++++---- .../connect/share/fabric/FriendCardIssuer.kt | 4 +- .../share/fabric/ui/FriendsViewModel.kt | 28 +++++----- .../share/fabric/FriendCardIssuerTest.kt | 8 +-- .../share/fabric/ui/FriendsViewModelTest.kt | 25 +++++---- 16 files changed, 245 insertions(+), 175 deletions(-) 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 index ea886d027..6981ad12a 100644 --- 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 @@ -33,7 +33,7 @@ data class FriendPermissions( ) enum class FriendRelationshipStatus { - PENDING_INCOMING, + PENDING_OUTGOING, CONFIRMED, } @@ -90,10 +90,10 @@ class FriendStore( } @Synchronized - fun pendingRequests(): List = + fun outgoingRequests(): List = read().filter { it.relationshipStatus == - FriendRelationshipStatus.PENDING_INCOMING + FriendRelationshipStatus.PENDING_OUTGOING } @Synchronized @@ -110,7 +110,7 @@ class FriendStore( ) @Synchronized - fun receiveRequest( + fun sendRequest( invitationUri: String, displayName: String, now: Instant = Instant.now(), @@ -119,12 +119,12 @@ class FriendStore( invitationUri = invitationUri, displayName = displayName, relationshipStatus = - FriendRelationshipStatus.PENDING_INCOMING, + FriendRelationshipStatus.PENDING_OUTGOING, now = now, ) @Synchronized - fun confirmPending( + fun confirmOutgoing( peerId: String, ): Either = update(peerId) { friend -> friend.copy( @@ -294,7 +294,7 @@ class FriendStore( ) val relationshipStatus = json .optionalString("relationshipStatus") - ?.let(FriendRelationshipStatus::valueOf) + ?.let(::parseRelationshipStatus) ?: legacyRelationshipStatus( minecraftUuid = minecraftUuid, permissions = parsedPermissions, @@ -433,7 +433,16 @@ class FriendStore( ) { FriendRelationshipStatus.CONFIRMED } else { - FriendRelationshipStatus.PENDING_INCOMING + 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 = 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 index d5dc67bf5..47fa5da03 100644 --- 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 @@ -24,31 +24,31 @@ class FriendStoreTest { lateinit var tempDir: Path @Test - fun `receiving a signed link stores only a pending request`() { + fun `sending a signed link stores only an outgoing request`() { val store = FriendStore(tempDir) val request = assertIs>( - store.receiveRequest(signedLink(), "Robin", NOW), + store.sendRequest(signedLink(), "Robin", NOW), ).value assertEquals( - FriendRelationshipStatus.PENDING_INCOMING, + FriendRelationshipStatus.PENDING_OUTGOING, request.relationshipStatus, ) assertTrue(store.all().isEmpty()) assertEquals( listOf(request), - FriendStore(tempDir).pendingRequests(), + FriendStore(tempDir).outgoingRequests(), ) } @Test - fun `confirming a pending request promotes it across restarts`() { + fun `confirming an outgoing request promotes it across restarts`() { val store = FriendStore(tempDir) - store.receiveRequest(signedLink(), "Robin", NOW) + store.sendRequest(signedLink(), "Robin", NOW) val confirmed = assertIs>( - store.confirmPending(PEER_ID), + store.confirmOutgoing(PEER_ID), ).value assertEquals( @@ -59,16 +59,16 @@ class FriendStoreTest { listOf(confirmed), FriendStore(tempDir).all(), ) - assertTrue(FriendStore(tempDir).pendingRequests().isEmpty()) + assertTrue(FriendStore(tempDir).outgoingRequests().isEmpty()) } @Test - fun `receiving the same link never demotes a confirmed friend`() { + fun `sending the same link never demotes a confirmed friend`() { val store = FriendStore(tempDir) store.accept(signedLink(), "Robin", NOW) val received = assertIs>( - store.receiveRequest(signedLink(), "Robin", NOW), + store.sendRequest(signedLink(), "Robin", NOW), ).value assertEquals( @@ -76,11 +76,11 @@ class FriendStoreTest { received.relationshipStatus, ) assertEquals(PEER_ID, store.all().single().peerId) - assertTrue(store.pendingRequests().isEmpty()) + assertTrue(store.outgoingRequests().isEmpty()) } @Test - fun `legacy unverified relationships migrate to pending`() { + fun `legacy unverified relationships migrate to outgoing`() { val store = FriendStore(tempDir) store.accept(signedLink(), "Robin", NOW) stripRelationshipStatus() @@ -88,7 +88,22 @@ class FriendStoreTest { val migrated = FriendStore(tempDir) assertTrue(migrated.all().isEmpty()) - assertEquals(PEER_ID, migrated.pendingRequests().single().peerId) + 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 @@ -104,7 +119,7 @@ class FriendStoreTest { val migrated = FriendStore(tempDir) assertEquals(PEER_ID, migrated.all().single().peerId) - assertTrue(migrated.pendingRequests().isEmpty()) + assertTrue(migrated.outgoingRequests().isEmpty()) } @Test @@ -246,6 +261,17 @@ class FriendStoreTest { 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 = 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 index ad8d412c3..ddd793d95 100644 --- 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 @@ -84,7 +84,7 @@ object FriendCardNetworking { FriendCardPayload(invitation), ) scope.launch(Dispatchers.IO) { - receiver.confirmPending(exchange.peerId) + receiver.confirmOutgoing(exchange.peerId) } } } 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 index 06cd8441c..03e746041 100644 --- 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 @@ -133,11 +133,11 @@ class ShareJoinScreen( ) val state = friends.state.value - val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - pending.size, + MAX_VISIBLE_RELATIONSHIPS - outgoing.size, ) - if (pending.isEmpty() && saved.isEmpty()) { + if (outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -145,7 +145,7 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - pending.forEachIndexed { index, request -> + outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 addRenderableWidget( StringWidget( @@ -154,7 +154,7 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.pending_request", + "connect_share.friends.outgoing_request", request.displayName, ), font, @@ -163,16 +163,16 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.accept_request", + "connect_share.friends.retry_request", ), ) { - joinPending(request.peerId) + joinOutgoing(request.peerId) }.bounds(width / 2 + 23, y, 62, 20).build(), ) addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.decline_request", + "connect_share.friends.cancel_request", ), ) { friends.remove(request.peerId) @@ -181,7 +181,7 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (pending.size + index) * 26 + val y = 58 + (outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -341,16 +341,17 @@ class ShareJoinScreen( primaryButton = addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.save_request", + "connect_share.friends.send_request", ), ) { - if (friends.receiveRequest(invitationValue, nameValue)) { - invitationValue = "" - nameValue = "" - mode = Mode.FRIENDS + val peerId = friends.sendRequest( + invitationValue, + nameValue, + ) + if (peerId == null) { rebuildWidgets() } else { - rebuildWidgets() + joinOutgoing(peerId) } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) @@ -555,7 +556,7 @@ class ShareJoinScreen( } } - private fun joinPending(peerId: String) { + private fun joinOutgoing(peerId: String) { if (joining) return joining = true joiningPeerId = peerId @@ -563,7 +564,7 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - friends.joinPending( + friends.joinOutgoing( peerId = peerId, browser = browser, authMode = authMode(), @@ -624,12 +625,17 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val pendingRequest = state.pendingRequests.firstOrNull { + val outgoingRequest = state.outgoingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( joiningFriend?.displayName - ?: pendingRequest?.displayName + ?: outgoingRequest?.let { + Component.translatable( + "connect_share.friends.connecting_request", + it.displayName, + ).string + } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -638,7 +644,7 @@ class ShareJoinScreen( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = joiningFriend?.permissions?.canSeeMyWorlds - ?: (pendingRequest != null), + ?: (outgoingRequest != null), ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( 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 index 43091293b..f03f09c8d 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Beitrittsanfragen", + "connect_share.status.requests": "Freundschafts- und Beitrittsanfragen", "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 · %s", - "connect_share.status.allow": "Erlauben", + "connect_share.status.request": "%s möchte sich verbinden · %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 den Beitritt.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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 Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", - "connect_share.friends.pending_request": "Anfrage von %s", - "connect_share.friends.accept_request": "Annehmen", - "connect_share.friends.decline_request": "Ablehnen", + "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.retry_request": "Erneut", + "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", "connect_share.friends.name": "Name des Freundes", - "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", "connect_share.friends.save": "Freund speichern", - "connect_share.friends.save_request": "Anfrage 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", @@ -76,8 +77,8 @@ "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": "Freund möchte beitreten", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.join_request": "Freundschafts- oder Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index aa60a7bf6..04a8a1cb2 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Join requests", + "connect_share.status.requests": "Friend and join requests", "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 · %s", - "connect_share.status.allow": "Allow", - "connect_share.status.deny": "Deny", + "connect_share.status.request": "%s wants to connect · %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 to join.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends or requests yet. Add a friend's link to send a request.", - "connect_share.friends.pending_request": "Request from %s", - "connect_share.friends.accept_request": "Accept", - "connect_share.friends.decline_request": "Decline", + "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", - "connect_share.friends.name": "Friend name", - "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", - "connect_share.friends.save_request": "Save request", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", @@ -76,8 +77,8 @@ "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": "Friend wants to join", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.join_request": "Friend or join request", + "connect_share.notification.join_request_detail": "%s is waiting. 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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index 5e878dc3d..11aef74e7 100644 --- 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 @@ -39,21 +39,28 @@ class Fabric12111ArtifactTest { "\"Copy my friend link\"" in language, ) assertTrue( - "\"connect_share.friends.save_request\": " + - "\"Save request\"" in language, + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, ) assertTrue( - "\"connect_share.friends.pending_request\": " + - "\"Request from %s\"" in language, + "\"connect_share.friends.outgoing_request\": " + + "\"Request to %s\"" in language, ) assertTrue( - "\"connect_share.friends.accept_request\": \"Accept\"" in + "\"connect_share.friends.retry_request\": \"Retry\"" in language, ) assertTrue( - "\"connect_share.friends.decline_request\": \"Decline\"" in + "\"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) } } @@ -73,13 +80,14 @@ class Fabric12111ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) - assertTrue("receiveRequest" in bytecode) - assertTrue("joinPending" in bytecode) + assertTrue("sendRequest" in bytecode) + assertTrue("joinOutgoing" in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) } } @Test - fun `approved card exchange promotes a pending request`() { + fun `approved card exchange promotes an outgoing request`() { JarFile(artifact().toFile()).use { jar -> val bytecode = jar.entries().asSequence() .filter { @@ -94,7 +102,7 @@ class Fabric12111ArtifactTest { } } - assertTrue("confirmPending" in bytecode) + assertTrue("confirmOutgoing" in bytecode) } } 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 index a8702303c..fe7ee0db0 100644 --- 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 @@ -84,7 +84,7 @@ object FriendCardNetworking { FriendCardPayload(invitation), ) scope.launch(Dispatchers.IO) { - receiver.confirmPending(exchange.peerId) + receiver.confirmOutgoing(exchange.peerId) } } } 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 index 8382a9f5c..514835ab1 100644 --- 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 @@ -133,11 +133,11 @@ class ShareJoinScreen( ) val state = friends.state.value - val pending = state.pendingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - pending.size, + MAX_VISIBLE_RELATIONSHIPS - outgoing.size, ) - if (pending.isEmpty() && saved.isEmpty()) { + if (outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -145,7 +145,7 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - pending.forEachIndexed { index, request -> + outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 addRenderableWidget( StringWidget( @@ -154,7 +154,7 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.pending_request", + "connect_share.friends.outgoing_request", request.displayName, ), font, @@ -163,16 +163,16 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.accept_request", + "connect_share.friends.retry_request", ), ) { - joinPending(request.peerId) + joinOutgoing(request.peerId) }.bounds(width / 2 + 23, y, 62, 20).build(), ) addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.decline_request", + "connect_share.friends.cancel_request", ), ) { friends.remove(request.peerId) @@ -181,7 +181,7 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (pending.size + index) * 26 + val y = 58 + (outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -341,16 +341,17 @@ class ShareJoinScreen( primaryButton = addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.save_request", + "connect_share.friends.send_request", ), ) { - if (friends.receiveRequest(invitationValue, nameValue)) { - invitationValue = "" - nameValue = "" - mode = Mode.FRIENDS + val peerId = friends.sendRequest( + invitationValue, + nameValue, + ) + if (peerId == null) { rebuildWidgets() } else { - rebuildWidgets() + joinOutgoing(peerId) } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) @@ -555,7 +556,7 @@ class ShareJoinScreen( } } - private fun joinPending(peerId: String) { + private fun joinOutgoing(peerId: String) { if (joining) return joining = true joiningPeerId = peerId @@ -563,7 +564,7 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - friends.joinPending( + friends.joinOutgoing( peerId = peerId, browser = browser, authMode = authMode(), @@ -623,12 +624,17 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val pendingRequest = state.pendingRequests.firstOrNull { + val outgoingRequest = state.outgoingRequests.firstOrNull { it.peerId == joiningPeerId } val data = ServerData( joiningFriend?.displayName - ?: pendingRequest?.displayName + ?: outgoingRequest?.let { + Component.translatable( + "connect_share.friends.connecting_request", + it.displayName, + ).string + } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -637,7 +643,7 @@ class ShareJoinScreen( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = joiningFriend?.permissions?.canSeeMyWorlds - ?: (pendingRequest != null), + ?: (outgoingRequest != null), ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( 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 index 43091293b..f03f09c8d 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Beitrittsanfragen", + "connect_share.status.requests": "Freundschafts- und Beitrittsanfragen", "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 · %s", - "connect_share.status.allow": "Erlauben", + "connect_share.status.request": "%s möchte sich verbinden · %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 den Beitritt.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "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 Anfragen. Füge einen Freundeslink hinzu, um eine Anfrage zu senden.", - "connect_share.friends.pending_request": "Anfrage von %s", - "connect_share.friends.accept_request": "Annehmen", - "connect_share.friends.decline_request": "Ablehnen", + "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.retry_request": "Erneut", + "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein. Ihr werdet erst nach Annahme und Beitritt Freunde.", + "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", "connect_share.friends.name": "Name des Freundes", - "connect_share.friends.name_hint": "Wie du die Person kennst", + "connect_share.friends.name_hint": "Name der Person, die den Link gesendet hat", "connect_share.friends.save": "Freund speichern", - "connect_share.friends.save_request": "Anfrage 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", @@ -76,8 +77,8 @@ "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": "Freund möchte beitreten", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Erlauben oder Ablehnen.", + "connect_share.notification.join_request": "Freundschafts- oder Beitrittsanfrage", + "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", "connect_share.notification.friend_online": "Welt eines Freundes ist bereit", "connect_share.notification.friend_online_detail": "%s ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index aa60a7bf6..04a8a1cb2 100644 --- 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 @@ -20,17 +20,17 @@ "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": "Join requests", + "connect_share.status.requests": "Friend and join requests", "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 · %s", - "connect_share.status.allow": "Allow", - "connect_share.status.deny": "Deny", + "connect_share.status.request": "%s wants to connect · %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 to join.", + "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.", @@ -44,23 +44,24 @@ "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.description": "Confirmed friends appear when their world is ready. Requests stay private until accepted.", + "connect_share.friends.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "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": "No friends or requests yet. Add a friend's link to send a request.", - "connect_share.friends.pending_request": "Request from %s", - "connect_share.friends.accept_request": "Accept", - "connect_share.friends.decline_request": "Decline", + "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.retry_request": "Retry", + "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste the link your friend sent. They become a friend only after you accept and join.", - "connect_share.friends.name": "Friend name", - "connect_share.friends.name_hint": "How you know them", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", - "connect_share.friends.save_request": "Save request", + "connect_share.friends.send_request": "Send request", + "connect_share.friends.connecting_request": "Friend request to %s", "connect_share.friends.join_once": "Join once", "connect_share.friends.manage_title": "Manage %s", "connect_share.friends.notify": "Notify me when their world is ready", @@ -76,8 +77,8 @@ "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": "Friend wants to join", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to allow or deny.", + "connect_share.notification.join_request": "Friend or join request", + "connect_share.notification.join_request_detail": "%s is waiting. 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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index c0ff26576..11d7337a4 100644 --- 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 @@ -39,21 +39,28 @@ class Fabric262ArtifactTest { "\"Copy my friend link\"" in language, ) assertTrue( - "\"connect_share.friends.save_request\": " + - "\"Save request\"" in language, + "\"connect_share.friends.send_request\": " + + "\"Send request\"" in language, ) assertTrue( - "\"connect_share.friends.pending_request\": " + - "\"Request from %s\"" in language, + "\"connect_share.friends.outgoing_request\": " + + "\"Request to %s\"" in language, ) assertTrue( - "\"connect_share.friends.accept_request\": \"Accept\"" in + "\"connect_share.friends.retry_request\": \"Retry\"" in language, ) assertTrue( - "\"connect_share.friends.decline_request\": \"Decline\"" in + "\"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) } } @@ -75,13 +82,14 @@ class Fabric262ArtifactTest { assertTrue( "connect_share.friends.remove_confirm.confirm" in bytecode, ) - assertTrue("receiveRequest" in bytecode) - assertTrue("joinPending" in bytecode) + assertTrue("sendRequest" in bytecode) + assertTrue("joinOutgoing" in bytecode) + assertFalse("connect_share.friends.accept_request" in bytecode) } } @Test - fun `approved card exchange promotes a pending request`() { + fun `approved card exchange promotes an outgoing request`() { JarFile(artifact().toFile()).use { jar -> val bytecode = jar.entries().asSequence() .filter { @@ -96,7 +104,7 @@ class Fabric262ArtifactTest { } } - assertTrue("confirmPending" in bytecode) + assertTrue("confirmOutgoing" in bytecode) } } 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 index 661872f3c..1316d647e 100644 --- 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 @@ -19,10 +19,10 @@ data object FriendCardIssueFailure class FriendCardReceiver( private val store: FriendStore, ) { - fun confirmPending( + fun confirmOutgoing( peerId: String, ): Either = - store.confirmPending(peerId) + store.confirmOutgoing(peerId) fun receive( invitation: String, 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 index 943bdf180..e6440d8e9 100644 --- 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 @@ -27,14 +27,14 @@ data class FriendSummary( val worldName: String? = null, ) -data class PendingFriendSummary( +data class OutgoingFriendRequestSummary( val peerId: String, val displayName: String, ) data class FriendsUiState( val friends: List = emptyList(), - val pendingRequests: List = emptyList(), + val outgoingRequests: List = emptyList(), val safeMessage: String? = null, ) @@ -47,19 +47,19 @@ class FriendsViewModel( val state: StateFlow = mutableState.asStateFlow() - fun receiveRequest( + fun sendRequest( invitationUri: String, displayName: String, now: Instant = Instant.now(), - ): Boolean = - store.receiveRequest(invitationUri, displayName, now).fold( + ): String? = + store.sendRequest(invitationUri, displayName, now).fold( ifLeft = { failure -> update { copy(safeMessage = failure.safeMessage) } - false + null }, - ifRight = { + ifRight = { request -> refresh() - true + request.peerId }, ) @@ -124,12 +124,12 @@ class FriendsViewModel( return browser.join(friend, authMode) } - suspend fun joinPending( + suspend fun joinOutgoing( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, ): Either { - val request = pendingRequest(peerId) + val request = outgoingRequest(peerId) ?: return GuestJoinFailure.NoRoute.left() return browser.join(request, authMode) } @@ -139,9 +139,9 @@ class FriendsViewModel( store.all().firstOrNull { it.peerId == peerId } }.getOrNull() - internal fun pendingRequest(peerId: String): SavedFriend? = + internal fun outgoingRequest(peerId: String): SavedFriend? = runCatching { - store.pendingRequests().firstOrNull { + store.outgoingRequests().firstOrNull { it.peerId == peerId } }.getOrNull() @@ -165,8 +165,8 @@ class FriendsViewModel( private fun currentState(): FriendsUiState = FriendsUiState( friends = store.all().map { it.summary() }, - pendingRequests = store.pendingRequests().map { - PendingFriendSummary( + outgoingRequests = store.outgoingRequests().map { + OutgoingFriendRequestSummary( peerId = it.peerId, displayName = it.displayName, ) 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 index 1955dc91e..a7e777ee3 100644 --- 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 @@ -90,7 +90,7 @@ class FriendCardIssuerTest { } @Test - fun `approved exchange promotes the accepter pending request`() = + fun `approved exchange promotes the sender outgoing request`() = runBlocking { val issuer = FriendCardIssuer( dataDirectory = tempDir.resolve("sender"), @@ -102,10 +102,10 @@ class FriendCardIssuerTest { .payload .peerId val store = FriendStore(tempDir.resolve("accepter")) - store.receiveRequest(card, "Robin", NOW) + store.sendRequest(card, "Robin", NOW) val receiver = FriendCardReceiver(store) - val result = receiver.confirmPending(peerId) + val result = receiver.confirmOutgoing(peerId) assertIs< Either.Right< @@ -113,7 +113,7 @@ class FriendCardIssuerTest { > >(result) assertEquals(peerId, store.all().single().peerId) - assertTrue(store.pendingRequests().isEmpty()) + assertTrue(store.outgoingRequests().isEmpty()) } @Test 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 index 1345927e1..fb2306fc2 100644 --- 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 @@ -35,12 +35,15 @@ class FriendsViewModelTest { lateinit var tempDir: Path @Test - fun `receiving one link exposes only a pending request`() { + fun `sending one link exposes only an outgoing request`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - assertTrue(viewModel.receiveRequest(signedLink(), "Robin", NOW)) + assertEquals( + PEER_ID, + viewModel.sendRequest(signedLink(), "Robin", NOW), + ) - val request = viewModel.state.value.pendingRequests.single() + val request = viewModel.state.value.outgoingRequests.single() assertEquals(PEER_ID, request.peerId) assertEquals("Robin", request.displayName) assertTrue(viewModel.state.value.friends.isEmpty()) @@ -49,9 +52,9 @@ class FriendsViewModelTest { } @Test - fun `pending request never exposes presence as a friend`() { + fun `outgoing request never exposes presence as a friend`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.receiveRequest(signedLink(), "Robin", NOW) + viewModel.sendRequest(signedLink(), "Robin", NOW) viewModel.updateRemotePresence( mapOf( @@ -68,7 +71,7 @@ class FriendsViewModelTest { assertTrue(viewModel.state.value.friends.isEmpty()) assertEquals( PEER_ID, - viewModel.state.value.pendingRequests.single().peerId, + viewModel.state.value.outgoingRequests.single().peerId, ) } @@ -76,13 +79,13 @@ class FriendsViewModelTest { fun `invalid friend link stays on the add flow with a useful message`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) - val accepted = viewModel.receiveRequest( + val accepted = viewModel.sendRequest( "minekube://share/not-a-valid-link", "Robin", NOW, ) - assertFalse(accepted) + assertEquals(null, accepted) assertTrue(viewModel.state.value.friends.isEmpty()) assertTrue(viewModel.state.value.safeMessage?.isNotBlank() == true) } @@ -212,7 +215,7 @@ class FriendsViewModelTest { } @Test - fun `accepting a pending request can join its signed route`() = runTest { + fun `retrying an outgoing request can join its signed route`() = runTest { val link = signedLink() val node = FakeGuestNode() val browser = FabricShareBrowser.testing( @@ -230,9 +233,9 @@ class FriendsViewModelTest { ), ) val viewModel = FriendsViewModel(FriendStore(tempDir)) - viewModel.receiveRequest(link, "Robin", NOW) + viewModel.sendRequest(link, "Robin", NOW) - val result = viewModel.joinPending( + val result = viewModel.joinOutgoing( peerId = PEER_ID, browser = browser, authMode = DirectP2pAuthMode.OFFLINE, From 38a3eea253be0e4957a222a30364b86a763793fa Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 10:12:40 +0200 Subject: [PATCH 127/188] fix(share): deliver friend requests without joining --- .../connect/share/CapturedServerTransport.kt | 7 + .../connect/share/MinecraftShareBridge.kt | 1 + .../connect/share/ShareCoordinator.kt | 2 +- .../connect/share/VersionedMinecraftBridge.kt | 5 +- .../share/admission/AdmissionController.kt | 75 +++- .../share/admission/AdmissionIdentity.kt | 6 + .../friend/FriendControlChannelHandler.kt | 187 ++++++++++ .../connect/share/friend/FriendControlWire.kt | 350 ++++++++++++++++++ .../connect/share/friend/FriendStore.kt | 8 +- .../connect/share/ShareCoordinatorTest.kt | 36 +- .../admission/AdmissionControllerTest.kt | 36 ++ .../friend/FriendControlChannelHandlerTest.kt | 160 ++++++++ .../share/friend/FriendControlWireTest.kt | 80 ++++ .../connect/share/friend/FriendStoreTest.kt | 17 + .../v1_21_11/ConnectShare12111Client.kt | 17 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 281 +++++++++++--- .../fabric/v1_21_11/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 23 +- .../fabric/v26_2/ConnectShare262Client.kt | 17 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 281 +++++++++++--- .../share/fabric/v26_2/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 23 +- .../share/fabric/ConnectShareClient.kt | 16 +- .../fabric/FabricSessionAdmissionGate.kt | 12 + .../share/fabric/FabricShareBootstrap.kt | 26 +- .../share/fabric/FriendRequestClient.kt | 232 ++++++++++++ .../share/fabric/FriendRequestServer.kt | 132 +++++++ .../share/fabric/ui/FriendsViewModel.kt | 31 +- .../fabric/FabricSessionAdmissionGateTest.kt | 22 ++ .../share/fabric/FriendRequestClientTest.kt | 162 ++++++++ .../share/fabric/FriendRequestServerTest.kt | 140 +++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 20 +- 36 files changed, 2297 insertions(+), 174 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt 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 index be28ccc49..8188cfbf5 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -5,6 +5,7 @@ import arrow.core.left import arrow.core.right import com.minekube.connect.share.direct.DirectSessionAttributes import com.minekube.connect.share.direct.DirectSessionRegistry +import com.minekube.connect.share.friend.FriendControlChannelRegistry import io.netty.channel.Channel import io.netty.channel.ChannelInitializer import io.netty.channel.EventLoopGroup @@ -36,6 +37,12 @@ object CapturedServerTransport { DirectSessionRegistry.claim(channel.remoteAddress())?.let { channel.attr(DirectSessionAttributes.SESSION).set(it) } + FriendControlChannelRegistry.createHandler()?.let { + channel.pipeline().addLast( + "connect-share-friend-control", + it, + ) + } channel.pipeline().addLast(initializer) } } 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 index 725063aee..91e29ae4f 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/MinecraftShareBridge.kt @@ -4,6 +4,7 @@ import java.net.SocketAddress data class LocalShareTarget( val address: SocketAddress, + val directAddress: SocketAddress = address, val close: suspend () -> Unit, ) 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 index 79ce59a12..0e5711200 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -70,7 +70,7 @@ class ShareCoordinator( acquire = { it.start( options = options, - target = target.address, + target = target.directAddress, connectAddress = connect?.publicAddress, ) }, 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 index 39733eaf6..8f0a35bf5 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -47,7 +47,10 @@ open class VersionedMinecraftBridge( val acquired = ActiveTransport(published, local, admission) active = acquired serverSocketAddress = local.address - LocalShareTarget(local.address) { + LocalShareTarget( + address = local.address, + directAddress = published.address, + ) { close(acquired) } } catch (failure: Throwable) { 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 index 42400cfb7..f1df32251 100644 --- 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 @@ -33,19 +33,30 @@ class AdmissionController( require(maxPending > 0) { "Maximum pending admissions must be positive" } } - suspend fun request(identity: AdmissionIdentity): AdmissionAnswer { + suspend fun request( + identity: AdmissionIdentity, + purpose: AdmissionPurpose = AdmissionPurpose.JOIN, + ): AdmissionAnswer { val lookup = synchronized(lock) { - val key = identity.admissionKey() + val key = identity.admissionKey(purpose) requests[key]?.let { + it.waiters++ return@synchronized RequestLookup.Await(it, startTimeout = false) } - if (connectedCount() >= maxGuests()) { + if ( + purpose == AdmissionPurpose.JOIN && + connectedCount() >= maxGuests() + ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } - if (autoApprove(identity)) { + if ( + purpose == AdmissionPurpose.JOIN && + autoApprove(identity) + ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) } if ( + purpose == AdmissionPurpose.JOIN && identity is AdmissionIdentity.Authenticated && identity.uuid in authenticatedApprovals ) { @@ -60,6 +71,7 @@ class AdmissionController( pending = PendingAdmission( requestId = UUID.randomUUID(), identity = identity, + purpose = purpose, ), ) requests[key] = request @@ -73,7 +85,11 @@ class AdmissionController( if (lookup.startTimeout) { startTimeout(lookup.request) } - lookup.request.answer.await() + try { + lookup.request.answer.await() + } finally { + releaseWaiter(lookup.request) + } } } } @@ -85,7 +101,10 @@ class AdmissionController( it.value.pending.requestId == requestId } ?: return requests.remove(entry.key) - if (allow) { + if ( + allow && + entry.value.pending.purpose == AdmissionPurpose.JOIN + ) { val identity = entry.value.pending.identity if (identity is AdmissionIdentity.Authenticated) { authenticatedApprovals += identity.uuid @@ -139,18 +158,51 @@ class AdmissionController( 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(): AdmissionKey = when (this) { - is AdmissionIdentity.Authenticated -> AdmissionKey.Authenticated(uuid) - is AdmissionIdentity.UnverifiedOffline -> AdmissionKey.Unverified(connectionId) + 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) : AdmissionKey - data class Unverified(val connectionId: String) : AdmissionKey + data class Authenticated( + val uuid: UUID, + val purpose: AdmissionPurpose, + ) : AdmissionKey + + data class Unverified( + val connectionId: String, + val purpose: AdmissionPurpose, + ) : AdmissionKey } private class PendingRequest( @@ -158,6 +210,7 @@ class AdmissionController( val pending: PendingAdmission, val answer: CompletableDeferred = CompletableDeferred(), val timeoutJob: AtomicReference = AtomicReference(), + var waiters: Int = 1, ) private sealed interface 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 index 2d270b391..60aadc558 100644 --- 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 @@ -43,7 +43,13 @@ enum class AdmissionAnswer { 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/friend/FriendControlChannelHandler.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt new file mode 100644 index 000000000..9bb1f98e7 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt @@ -0,0 +1,187 @@ +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.ChannelHandler +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 +} + +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.inspectControlHandshake(accumulated) + ) { + FriendControlDecode.Incomplete -> return + FriendControlDecode.Invalid -> { + context.close() + return + } + + is FriendControlDecode.Decoded -> { + if (!inspected.value) { + passThrough(context, accumulated) + return + } + controlHandshake = true + } + } + } + + when (val decoded = FriendControlWire.decodeRequest(accumulated)) { + FriendControlDecode.Incomplete -> Unit + FriendControlDecode.Invalid -> context.close() + is FriendControlDecode.Decoded -> { + if (decoded.consumedBytes != accumulated.size) { + context.close() + return + } + beginRequest(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) + val pending = server.handle(context.controlContext(), request) + 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 { + val direct = channel() + .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(), + ) + } +} + +object FriendControlChannelRegistry { + private val installed = AtomicReference() + + fun install(server: FriendControlServer): AutoCloseable { + check(installed.compareAndSet(null, server)) { + "A friend control server is already installed" + } + return AutoCloseable { + installed.compareAndSet(server, null) + } + } + + fun createHandler(): ChannelHandler? = + installed.get()?.let(::FriendControlChannelHandler) +} 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..0208caa61 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt @@ -0,0 +1,350 @@ +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 displayName: String, + val invitation: String, +) + +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 +} + +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 + const val CONTROL_HANDSHAKE_PORT = 24_454 + + private const val STATUS_INTENTION = 1 + private const val HANDSHAKE_PACKET_ID = 0 + private const val STATUS_REQUEST_PACKET_ID = 0 + private const val CONTROL_REQUEST_PACKET_ID = 0x43F1 + private const val CONTROL_RESPONSE_PACKET_ID = 0x43F2 + private const val MAX_ADDRESS_BYTES = 255 + private const val MAX_DISPLAY_NAME_BYTES = 256 + private const val MAX_INVITATION_BYTES = 32_768 + + fun encodeRequest( + protocolVersion: Int, + serverAddress: String, + request: FriendControlRequest, + ): ByteArray { + require(protocolVersion >= 0) { + "Minecraft protocol version must not be negative" + } + require(serverAddress.toByteArray(StandardCharsets.UTF_8).size <= MAX_ADDRESS_BYTES) { + "Minecraft server address is too long" + } + 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(HANDSHAKE_PACKET_ID) + writeVarInt(protocolVersion) + writeString(serverAddress) + write((CONTROL_HANDSHAKE_PORT ushr 8) and 0xff) + write(CONTROL_HANDSHAKE_PORT and 0xff) + writeVarInt(STATUS_INTENTION) + } + output.writePacket { + writeVarInt(STATUS_REQUEST_PACKET_ID) + } + output.writePacket { + writeVarInt(CONTROL_REQUEST_PACKET_ID) + writeLong(request.requestId.mostSignificantBits) + writeLong(request.requestId.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 + } + return decode(bytes) { + val handshake = readPacket() + ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) + handshake.readVarInt() + handshake.readString(MAX_ADDRESS_BYTES) + ensure(handshake.readUnsignedShort() == CONTROL_HANDSHAKE_PORT) + ensure(handshake.readVarInt() == STATUS_INTENTION) + handshake.ensureFinished() + + val statusRequest = readPacket() + ensure( + statusRequest.readVarInt() == STATUS_REQUEST_PACKET_ID, + ) + statusRequest.ensureFinished() + + 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, + displayName = displayName, + invitation = invitation, + ) + } + } + + 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 inspectControlHandshake( + bytes: ByteArray, + ): FriendControlDecode = decode(bytes) { + val handshake = readPacket() + ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) + handshake.readVarInt() + handshake.readString(MAX_ADDRESS_BYTES) + val port = handshake.readUnsignedShort() + val intention = handshake.readVarInt() + handshake.ensureFinished() + port == CONTROL_HANDSHAKE_PORT && + intention == STATUS_INTENTION + } + + 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) + } + } + return output.toByteArray() + } + + 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 + 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.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 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 index 6981ad12a..382655770 100644 --- 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 @@ -83,6 +83,8 @@ sealed interface FriendStoreError { class FriendStore( private val directory: Path, ) { + private var cached: List? = null + @Synchronized fun all(): List = read().filter { @@ -230,7 +232,10 @@ class FriendStore( updated } - private fun read(): List { + private fun read(): List = + cached ?: load().also { cached = it } + + private fun load(): List { Files.createDirectories(directory) if (!Files.exists(friendsFile)) { return emptyList() @@ -359,6 +364,7 @@ class FriendStore( add("friends", entries) } writeAtomic(GSON.toJson(root)) + cached = friends.toList() } private fun writeAtomic(content: String) { 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 index a2c8c9a03..90ec30ea3 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -25,6 +25,36 @@ 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() @@ -311,6 +341,9 @@ class ShareCoordinatorTest { 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, @@ -345,7 +378,8 @@ class ShareCoordinatorTest { val bridge = MinecraftShareBridge { events += "bridge-open" LocalShareTarget( - address = InetSocketAddress.createUnresolved("127.0.0.1", 25565), + address = connectTarget, + directAddress = directTarget, close = { events += "bridge-close" }, 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 index 5af01224c..d6613a9d4 100644 --- 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 @@ -7,12 +7,48 @@ 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 `authenticated UUID approval is reused only during current share`() = runTest { val controller = controller() 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..551f0179d --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandlerTest.kt @@ -0,0 +1,160 @@ +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 = FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "localhost", + request = REQUEST, + ).copyOf() + val controlHigh = + FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 + val controlLow = + FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff + val portIndex = ordinary.indices.first { + it + 1 < ordinary.size && + ordinary[it].toInt() and 0xff == controlHigh && + ordinary[it + 1].toInt() and 0xff == controlLow + } + ordinary[portIndex] = (25_565 ushr 8).toByte() + ordinary[portIndex + 1] = 25_565.toByte() + 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( + protocolVersion = 1_075, + serverAddress = "connect-share", + 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( + protocolVersion = 1_075, + serverAddress = "purple-del.play.minekube.net", + request = REQUEST, + ), + ), + ) + channel.readOutbound()?.release() + + channel.close() + + assertTrue(response.isCancelled) + 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", + ) + } +} 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..64b7dae81 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt @@ -0,0 +1,80 @@ +package com.minekube.connect.share.friend + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class FriendControlWireTest { + @Test + fun `request uses a status handshake and round trips without a login`() { + val request = FriendControlRequest( + requestId = REQUEST_ID, + displayName = "bob", + invitation = "minekube://share/signed-bob-card", + ) + + val encoded = FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "purple-del.play.minekube.net", + request = request, + ) + val decoded = assertIs>( + FriendControlWire.decodeRequest(encoded), + ) + + assertEquals(request, decoded.value) + assertEquals(encoded.size, decoded.consumedBytes) + assertTrue(FriendControlWire.isStatusHandshake(encoded)) + } + + @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, + ) + + 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 `partial and oversized control frames are never accepted`() { + val encoded = FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "purple-del.play.minekube.net", + 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") + } +} 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 index 47fa5da03..299367132 100644 --- 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 @@ -23,6 +23,23 @@ 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) 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 index e1bc245a9..6237e7c3d 100644 --- 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 @@ -1,6 +1,7 @@ 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.FabricLocalLoginAdmission import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate @@ -47,7 +48,9 @@ class ConnectShare12111Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), + minecraftProtocolVersion = SharedConstants.getProtocolVersion(), worldAvailable = client.hasSingleplayerServer(), + friendStore = friendStore, playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, @@ -97,7 +100,7 @@ class ConnectShare12111Client : ClientModInitializer { FriendCardNetworking.install( scope = scope, issuer = installation.friendCardIssuer, - receiver = FriendCardReceiver(friendStore), + receiver = installation.friendCardReceiver, approvedJoins = installation.approvedJoins, ) ConnectShareClient.install(installation) @@ -121,10 +124,18 @@ class ConnectShare12111Client : ClientModInitializer { minecraft.toastManager, admissionToastId, Component.translatable( - "connect_share.notification.join_request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, ), Component.translatable( - "connect_share.notification.join_request_detail", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, request.identity.name, ), ) 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 index 03e746041..879901f31 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.friend.FriendControlRequest import com.minekube.connect.share.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -14,6 +15,7 @@ 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 @@ -27,6 +29,7 @@ import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component +import java.util.UUID class ShareJoinScreen( private val parent: Screen, @@ -55,6 +58,10 @@ class ShareJoinScreen( private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() override fun init() { if (scope == null) { @@ -147,15 +154,16 @@ class ShareJoinScreen( } outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 + val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( width / 2 - 155, y, 174, 20, - Component.translatable( - "connect_share.friends.outgoing_request", + outgoingRequestLabel( request.displayName, + deliveryState, ), font, ), @@ -163,11 +171,15 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.retry_request", + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", ), ) { - joinOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, ) addRenderableWidget( Button.builder( @@ -175,8 +187,7 @@ class ShareJoinScreen( "connect_share.friends.cancel_request", ), ) { - friends.remove(request.peerId) - rebuildWidgets() + cancelOutgoing(request.peerId) }.bounds(width / 2 + 89, y, 66, 20).build(), ) } @@ -344,15 +355,7 @@ class ShareJoinScreen( "connect_share.friends.send_request", ), ) { - val peerId = friends.sendRequest( - invitationValue, - nameValue, - ) - if (peerId == null) { - rebuildWidgets() - } else { - joinOutgoing(peerId) - } + createFriendRequest() }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) secondaryButton = addRenderableWidget( @@ -442,19 +445,28 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.save_changes"), ) { - friends.rename(friend.peerId, nameValue) - friends.updatePermissions( - friend.peerId, - FriendPermissions( - notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, - canJoinAutomatically = autoJoin.selected(), - ), - ) - mode = Mode.FRIENDS - selectedPeerId = null - rebuildWidgets() + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = + autoJoin.selected(), + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -497,12 +509,20 @@ class ShareJoinScreen( "connect_share.friends.remove_confirm.confirm", ), ) { - friends.remove(friend.peerId) - removeConfirmation = false - mode = Mode.FRIENDS - selectedPeerId = null - nameValue = "" - rebuildWidgets() + 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 + nameValue = "" + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -556,25 +576,160 @@ class ShareJoinScreen( } } - private fun joinOutgoing(peerId: String) { - if (joining) return - joining = true - joiningPeerId = peerId - reciprocalPairing = true + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true safeMessage = null refresh() - scope?.launch { - friends.joinOutgoing( + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, authMode = authMode(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -625,17 +780,8 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val outgoingRequest = state.outgoingRequests.firstOrNull { - it.peerId == joiningPeerId - } val data = ServerData( joiningFriend?.displayName - ?: outgoingRequest?.let { - Component.translatable( - "connect_share.friends.connecting_request", - it.displayName, - ).string - } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -643,8 +789,7 @@ class ShareJoinScreen( val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds - ?: (outgoingRequest != null), + joiningFriend?.permissions?.canSeeMyWorlds == true, ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( @@ -664,7 +809,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() primaryButton?.active = - !joining && friendLinkState != FriendLinkState.COPYING && + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -703,6 +849,18 @@ class ShareJoinScreen( ) } + 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 @@ -750,6 +908,15 @@ class ShareJoinScreen( 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 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 index d820d99de..387f40dce 100644 --- 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 @@ -3,6 +3,7 @@ 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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -106,7 +107,11 @@ class ShareStatusScreen( "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( - "connect_share.status.request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, identity.name, badge, ) 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 index f03f09c8d..c9feb35ec 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -77,8 +84,10 @@ "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": "Freundschafts- oder Beitrittsanfrage", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "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 ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index 04a8a1cb2..1f1649285 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -77,8 +84,10 @@ "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": "Friend or join request", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to accept or decline.", + "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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index 11aef74e7..589eca7b3 100644 --- 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 @@ -67,21 +67,26 @@ class Fabric12111ArtifactTest { @Test fun `friend removal confirmation stays inside the friends screen`() { JarFile(artifact().toFile()).use { jar -> - val screen = jar.getJarEntry( - "com/minekube/connect/share/fabric/v1_21_11/" + - "ShareJoinScreen.class", - ) - assertNotNull(screen) - val bytecode = jar.getInputStream(screen).use { - it.readBytes().toString(Charsets.ISO_8859_1) - } + 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("joinOutgoing" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("joinOutgoing" !in bytecode) assertFalse("connect_share.friends.accept_request" in bytecode) } } 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 index 34910bf48..2b43f0276 100644 --- 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 @@ -1,6 +1,7 @@ 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.FabricLocalLoginAdmission import com.minekube.connect.share.fabric.FabricLocalLoginAdmissionGate @@ -48,7 +49,9 @@ class ConnectShare262Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = SharedConstants.getCurrentVersion().name(), + minecraftProtocolVersion = SharedConstants.getProtocolVersion(), worldAvailable = client.hasSingleplayerServer(), + friendStore = friendStore, playerCount = { client.singleplayerServer?.playerList?.playerCount ?: 0 }, @@ -98,7 +101,7 @@ class ConnectShare262Client : ClientModInitializer { FriendCardNetworking.install( scope = scope, issuer = installation.friendCardIssuer, - receiver = FriendCardReceiver(friendStore), + receiver = installation.friendCardReceiver, approvedJoins = installation.approvedJoins, ) ConnectShareClient.install(installation) @@ -122,10 +125,18 @@ class ConnectShare262Client : ClientModInitializer { minecraft.gui.toastManager(), admissionToastId, Component.translatable( - "connect_share.notification.join_request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request" + } else { + "connect_share.notification.join_request" + }, ), Component.translatable( - "connect_share.notification.join_request_detail", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.notification.friend_request_detail" + } else { + "connect_share.notification.join_request_detail" + }, request.identity.name, ), ) 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 index 514835ab1..d109173a3 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.GuestJoinTarget +import com.minekube.connect.share.friend.FriendControlRequest import com.minekube.connect.share.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -14,6 +15,7 @@ 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 @@ -27,6 +29,7 @@ import net.minecraft.client.multiplayer.ServerData import net.minecraft.client.multiplayer.resolver.ServerAddress import net.minecraft.network.chat.CommonComponents import net.minecraft.network.chat.Component +import java.util.UUID class ShareJoinScreen( private val parent: Screen, @@ -55,6 +58,10 @@ class ShareJoinScreen( private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE + private var requestOperationInProgress = false + private val requestJobs = mutableMapOf() + private val requestStates = + mutableMapOf() override fun init() { if (scope == null) { @@ -147,15 +154,16 @@ class ShareJoinScreen( } outgoing.forEachIndexed { index, request -> val y = 58 + index * 26 + val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( width / 2 - 155, y, 174, 20, - Component.translatable( - "connect_share.friends.outgoing_request", + outgoingRequestLabel( request.displayName, + deliveryState, ), font, ), @@ -163,11 +171,15 @@ class ShareJoinScreen( addRenderableWidget( Button.builder( Component.translatable( - "connect_share.friends.retry_request", + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", ), ) { - joinOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, ) addRenderableWidget( Button.builder( @@ -175,8 +187,7 @@ class ShareJoinScreen( "connect_share.friends.cancel_request", ), ) { - friends.remove(request.peerId) - rebuildWidgets() + cancelOutgoing(request.peerId) }.bounds(width / 2 + 89, y, 66, 20).build(), ) } @@ -344,15 +355,7 @@ class ShareJoinScreen( "connect_share.friends.send_request", ), ) { - val peerId = friends.sendRequest( - invitationValue, - nameValue, - ) - if (peerId == null) { - rebuildWidgets() - } else { - joinOutgoing(peerId) - } + createFriendRequest() }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) secondaryButton = addRenderableWidget( @@ -442,19 +445,28 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.save_changes"), ) { - friends.rename(friend.peerId, nameValue) - friends.updatePermissions( - friend.peerId, - FriendPermissions( - notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, - canJoinAutomatically = autoJoin.selected(), - ), - ) - mode = Mode.FRIENDS - selectedPeerId = null - rebuildWidgets() + val activeScope = scope ?: return@builder + requestOperationInProgress = true + refresh() + activeScope.launch { + withContext(Dispatchers.IO) { + friends.rename(friend.peerId, nameValue) + friends.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = + friend.permissions.canSeeMyWorlds, + canJoinAutomatically = + autoJoin.selected(), + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 52, 150, 20).build(), ) addRenderableWidget( @@ -497,12 +509,20 @@ class ShareJoinScreen( "connect_share.friends.remove_confirm.confirm", ), ) { - friends.remove(friend.peerId) - removeConfirmation = false - mode = Mode.FRIENDS - selectedPeerId = null - nameValue = "" - rebuildWidgets() + 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 + nameValue = "" + rebuildWidgets() + } }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) addRenderableWidget( @@ -556,25 +576,160 @@ class ShareJoinScreen( } } - private fun joinOutgoing(peerId: String) { - if (joining) return - joining = true - joiningPeerId = peerId - reciprocalPairing = true + private fun createFriendRequest() { + val activeScope = scope ?: return + if ( + requestOperationInProgress || + invitationValue.isBlank() || + nameValue.isBlank() + ) { + return + } + requestOperationInProgress = true safeMessage = null refresh() - scope?.launch { - friends.joinOutgoing( + activeScope.launch { + val peerId = withContext(Dispatchers.IO) { + friends.sendRequest( + invitationValue, + nameValue, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, authMode = authMode(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + requestStates[peerId] = RequestDeliveryState.FAILED + safeMessage = message + rebuildWidgets() + } + private fun joinInvitation() { if (joining || invitationValue.isBlank()) return joining = true @@ -624,17 +779,8 @@ class ShareJoinScreen( val joiningFriend = state.friends.firstOrNull { it.peerId == joiningPeerId } - val outgoingRequest = state.outgoingRequests.firstOrNull { - it.peerId == joiningPeerId - } val data = ServerData( joiningFriend?.displayName - ?: outgoingRequest?.let { - Component.translatable( - "connect_share.friends.connecting_request", - it.displayName, - ).string - } ?: "Connect Share", address.toString(), ServerData.Type.OTHER, @@ -642,8 +788,7 @@ class ShareJoinScreen( val exchangeFriendCard = FriendCardExchangeConsent.shouldArm( savedFriendJoin = reciprocalPairing, canSeeMyWorlds = - joiningFriend?.permissions?.canSeeMyWorlds - ?: (outgoingRequest != null), + joiningFriend?.permissions?.canSeeMyWorlds == true, ) if (exchangeFriendCard && joiningPeerId != null) { ConnectShareClient.armFriendCardExchange( @@ -663,7 +808,8 @@ class ShareJoinScreen( private fun refresh() { val inputReady = invitationValue.isNotBlank() primaryButton?.active = - !joining && friendLinkState != FriendLinkState.COPYING && + !joining && !requestOperationInProgress && + friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() Mode.MANAGE -> nameValue.isNotBlank() @@ -702,6 +848,18 @@ class ShareJoinScreen( ) } + 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 @@ -749,6 +907,15 @@ class ShareJoinScreen( 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 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 index c3b3ace0d..7de617042 100644 --- 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 @@ -3,6 +3,7 @@ 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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose import com.minekube.connect.share.fabric.ConnectShareClient import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.StringWidget @@ -106,7 +107,11 @@ class ShareStatusScreen( "offline · ${identity.ingress.displayName()}" } val label = Component.translatable( - "connect_share.status.request", + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, identity.name, badge, ) 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 index f03f09c8d..c9feb35ec 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_accepted": "%s hat deine Freundschaftsanfrage angenommen.", "connect_share.friends.cancel_request": "Abbrechen", "connect_share.friends.manage": "Verwalten", "connect_share.friends.add": "Freund hinzufügen", @@ -77,8 +84,10 @@ "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": "Freundschafts- oder Beitrittsanfrage", - "connect_share.notification.join_request_detail": "%s wartet. Öffne Connect Share zum Annehmen oder Ablehnen.", + "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 ist online. Öffne Freunde zum Beitreten.", "connect_share.identity.manage": "Erweiterte Einstellungen…", 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 index 04a8a1cb2..1f1649285 100644 --- 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 @@ -27,6 +27,7 @@ "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", @@ -52,7 +53,13 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", @@ -77,8 +84,10 @@ "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": "Friend or join request", - "connect_share.notification.join_request_detail": "%s is waiting. Open Connect Share to accept or decline.", + "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 online. Open Friends to join.", "connect_share.identity.manage": "Advanced settings…", 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 index 11d7337a4..819fb224b 100644 --- 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 @@ -67,14 +67,18 @@ class Fabric262ArtifactTest { @Test fun `friend removal confirmation stays inside the friends screen`() { JarFile(artifact().toFile()).use { jar -> - val screen = jar.getJarEntry( - "com/minekube/connect/share/fabric/v26_2/" + - "ShareJoinScreen.class", - ) - assertNotNull(screen) - val bytecode = jar.getInputStream(screen).use { - it.readBytes().toString(Charsets.ISO_8859_1) - } + 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, @@ -83,7 +87,8 @@ class Fabric262ArtifactTest { "connect_share.friends.remove_confirm.confirm" in bytecode, ) assertTrue("sendRequest" in bytecode) - assertTrue("joinOutgoing" in bytecode) + assertTrue("FriendRequestClient" in bytecode) + assertTrue("joinOutgoing" !in bytecode) assertFalse("connect_share.friends.accept_request" in bytecode) } } 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 index b85069c23..a9f13c2e9 100644 --- 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 @@ -15,7 +15,10 @@ data class ConnectShareInstallation( val viewModel: ShareViewModel, val runtime: ConnectShareRuntime, val friendCardIssuer: FriendCardIssuer, + val friendCardReceiver: FriendCardReceiver, + val friendRequestClient: FriendRequestClient, val approvedJoins: ApprovedJoinTracker, + val friendControlLease: AutoCloseable, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -76,6 +79,14 @@ object ConnectShareClient { fun friendCardIssuer(): FriendCardIssuer = checkNotNull(installation).friendCardIssuer + @JvmStatic + fun friendCardReceiver(): FriendCardReceiver = + checkNotNull(installation).friendCardReceiver + + @JvmStatic + fun friendRequestClient(): FriendRequestClient = + checkNotNull(installation).friendRequestClient + @JvmStatic fun armFriendCardExchange(peerId: String) { friendCardConsent.arm(peerId) @@ -97,7 +108,10 @@ object ConnectShareClient { fun shutdown() { friendCardConsent.cancel() guestLease.close() - installation?.runtime?.shutdown() + installation?.let { installed -> + installed.friendControlLease.close() + installed.runtime.shutdown() + } } private fun isShareActive(): Boolean = when ( 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 index 9c9bdcfbf..7637b0cb5 100644 --- 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 @@ -34,6 +34,11 @@ class FabricSessionAdmissionGate( override fun request( proposal: SessionProposal, ): CompletionStage { + if (proposal.isStatusProbe()) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.allow(), + ) + } if (proposal.session.auth.passthrough) { return CompletableFuture.completedFuture( SessionAdmissionDecision.deferToLocalLogin(), @@ -85,6 +90,13 @@ class FabricSessionAdmissionGate( 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 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 index 89fe2f6fd..a95e58d88 100644 --- 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 @@ -9,6 +9,7 @@ import com.minekube.connect.share.admission.AdmissionIdentity import com.minekube.connect.share.fabric.ui.ShareViewModel import com.minekube.connect.share.fabric.ui.StoredEndpointIdentityUiActions import com.minekube.connect.share.friend.FriendStore +import com.minekube.connect.share.friend.FriendControlChannelRegistry import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -28,7 +29,9 @@ object FabricShareBootstrap { scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, + minecraftProtocolVersion: Int, worldAvailable: Boolean, + friendStore: FriendStore, playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, bridgeFactory: @@ -44,7 +47,6 @@ object FabricShareBootstrap { httpClient: OkHttpClient = OkHttpClient(), ): ConnectShareInstallation { val viewModelReference = AtomicReference() - val friendStore = FriendStore(dataDirectory) val approvedJoins = ApprovedJoinTracker() val admission = AdmissionController( scope = scope, @@ -146,13 +148,29 @@ object FabricShareBootstrap { resumeShare = viewModel::resumeIfEnabled, worldAvailabilityChanged = viewModel::setWorldAvailable, ) + val friendCardIssuer = FriendCardIssuer(dataDirectory) { + "${identityStore.currentOrCreate().endpoint}.play.minekube.net" + } + val friendCardReceiver = FriendCardReceiver(friendStore) + val friendRequestServer = FriendRequestServer( + scope = scope, + admission = admission, + issuer = friendCardIssuer, + receiver = friendCardReceiver, + friendStore = friendStore, + ) + val friendControlLease = + FriendControlChannelRegistry.install(friendRequestServer) return ConnectShareInstallation( viewModel = viewModel, runtime = runtime, - friendCardIssuer = FriendCardIssuer(dataDirectory) { - "${identityStore.currentOrCreate().endpoint}.play.minekube.net" - }, + friendCardIssuer = friendCardIssuer, + friendCardReceiver = friendCardReceiver, + friendRequestClient = FriendRequestClient( + minecraftProtocolVersion, + ), approvedJoins = approvedJoins, + friendControlLease = friendControlLease, screens = screens, guestScreens = guestScreens, ) 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..d0135036d --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestClient.kt @@ -0,0 +1,232 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +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 java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.InetSocketAddress +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" + } +} + +class FriendRequestClient( + private val protocolVersion: Int, + 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, + request: FriendControlRequest, + onReceived: () -> Unit, + ): Either = withContext(ioDispatcher) { + target.use { + val route = target.routeTarget() + val socket = Socket() + val cancellation = coroutineContext[Job] + ?.invokeOnCompletion { socket.close() } + try { + socket.connect( + route.socketAddress, + connectTimeout.toMillis().toInt(), + ) + socket.soTimeout = READ_POLL_MILLIS + socket.getOutputStream().apply { + write( + FriendControlWire.encodeRequest( + protocolVersion = protocolVersion, + serverAddress = route.handshakeAddress, + 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() + } + } + outcome + } 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 fun GuestJoinTarget.routeTarget(): RouteTarget = when (this) { + is GuestJoinTarget.Connect -> { + val parsed = parseAddress(publicAddress) + RouteTarget( + socketAddress = parsed, + handshakeAddress = parsed.hostString, + ) + } + + is GuestJoinTarget.Direct -> RouteTarget( + socketAddress = localAddress, + handshakeAddress = "connect-share", + ) + } + + private fun parseAddress(value: String): InetSocketAddress { + val trimmed = value.trim() + if (trimmed.startsWith("[")) { + val closing = trimmed.indexOf(']') + require(closing > 1) { "Friend address is invalid" } + val host = trimmed.substring(1, closing) + val port = trimmed.substring(closing + 1) + .removePrefix(":") + .takeIf(String::isNotEmpty) + ?.toInt() + ?: DEFAULT_MINECRAFT_PORT + return InetSocketAddress(host, port) + } + val colon = trimmed.lastIndexOf(':') + val hasSingleColon = + colon > 0 && trimmed.indexOf(':') == colon + val host = if (hasSingleColon) { + trimmed.substring(0, colon) + } else { + trimmed + } + val port = if (hasSingleColon) { + trimmed.substring(colon + 1).toInt() + } else { + DEFAULT_MINECRAFT_PORT + } + return InetSocketAddress(host, port) + } + + private data class RouteTarget( + val socketAddress: InetSocketAddress, + val handshakeAddress: String, + ) + + private companion object { + const val DEFAULT_MINECRAFT_PORT = 25_565 + 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..65a0b2d32 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt @@ -0,0 +1,132 @@ +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.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.FriendControlServer +import com.minekube.connect.share.friend.FriendStore +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 now: () -> Instant = Instant::now, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : FriendControlServer { + 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 + } + + private suspend fun process( + context: FriendControlContext, + request: FriendControlRequest, + ): FriendControlResponse { + val instant = now() + val invitation = ShareInviteCodec.decode( + request.invitation, + instant, + ).getOrNull() ?: return FriendControlResponse.Invalid + val senderPeerId = invitation.payload.peerId + if ( + context.directPeerId != null && + context.directPeerId != senderPeerId + ) { + return FriendControlResponse.Invalid + } + val senderKey = Base64.getEncoder() + .encodeToString(invitation.publicKey) + val existing = friendStore.all().firstOrNull { + it.peerId == senderPeerId + } + if (existing != null) { + if (existing.publicKeyBase64 != senderKey) { + return FriendControlResponse.Invalid + } + 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, + now = instant, + ) + if (received.isLeft()) { + FriendControlResponse.Invalid + } else { + 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 CompletableFuture.cancelJobWhenCancelled( + job: Job, + ) { + whenComplete { _, _ -> + if (isCancelled) { + job.cancel() + } + } + } +} 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 index e6440d8e9..3f1d9281c 100644 --- 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 @@ -103,15 +103,21 @@ class FriendsViewModel( ) fun updatePresence(discovered: List) { + if (this.discovered == discovered) { + return + } this.discovered = discovered - refresh() + refresh(preserveSafeMessage = true) } fun updateRemotePresence( presence: Map, ) { + if (remotePresence == presence) { + return + } remotePresence = presence - refresh() + refresh(preserveSafeMessage = true) } suspend fun join( @@ -124,7 +130,7 @@ class FriendsViewModel( return browser.join(friend, authMode) } - suspend fun joinOutgoing( + suspend fun routeOutgoing( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, @@ -134,6 +140,10 @@ class FriendsViewModel( return browser.join(request, authMode) } + fun reload() { + refresh() + } + internal fun savedFriend(peerId: String): SavedFriend? = runCatching { store.all().firstOrNull { it.peerId == peerId } @@ -146,9 +156,20 @@ class FriendsViewModel( } }.getOrNull() - private fun refresh() { + private fun refresh( + preserveSafeMessage: Boolean = false, + ) { mutableState.value = try { - currentState() + currentState().let { next -> + if (preserveSafeMessage) { + next.copy( + safeMessage = + mutableState.value.safeMessage, + ) + } else { + next + } + } } catch (_: Exception) { mutableState.value.copy( safeMessage = FRIENDS_LOAD_FAILURE, 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 index 7324fef45..a86286a05 100644 --- 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 @@ -23,6 +23,28 @@ import minekube.connect.v1alpha1.WatchServiceOuterClass.Session @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class FabricSessionAdmissionGateTest { + @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() 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..8002beda7 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt @@ -0,0 +1,162 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +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 java.io.ByteArrayOutputStream +import java.net.InetAddress +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 `Connect 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( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + ) + + val result = client.exchange( + target = GuestJoinTarget.Connect( + "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", + ), + 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( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + decisionTimeout = Duration.ofSeconds(30), + ) + val pending = launch { + client.exchange( + target = GuestJoinTarget.Connect( + "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", + ), + request = REQUEST, + onReceived = {}, + ) + } + delay(100) + + pending.cancelAndJoin() + + assertTrue(closed.await(2, TimeUnit.SECONDS)) + remote.join(1_000) + } + + 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 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" + } +} 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..0cf4be038 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt @@ -0,0 +1,140 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.admission.AdmissionController +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.FriendStore +import java.nio.file.Path +import java.time.Instant +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.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +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) + assertTrue(hostStore.all().single().permissions.canJoinAutomatically) + } + + @Test + fun `decline 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() + admission.answer( + admission.pending.value.single().requestId, + allow = false, + ) + runCurrent() + + assertEquals(FriendControlResponse.Declined, connect.getNow(null)) + assertTrue(hostStore.all().isEmpty()) + } + + 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") + } +} 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 index fb2306fc2..f65b6c38b 100644 --- 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 @@ -90,6 +90,24 @@ class FriendsViewModelTest { assertTrue(viewModel.state.value.safeMessage?.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) @@ -235,7 +253,7 @@ class FriendsViewModelTest { val viewModel = FriendsViewModel(FriendStore(tempDir)) viewModel.sendRequest(link, "Robin", NOW) - val result = viewModel.joinOutgoing( + val result = viewModel.routeOutgoing( peerId = PEER_ID, browser = browser, authMode = DirectP2pAuthMode.OFFLINE, From 1c48de24ae4e52dea519e17e816fe6d7662463ad Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 11:22:53 +0200 Subject: [PATCH 128/188] fix(share): keep friend transports reachable from title --- .../connect/share/CapturedServerTransport.kt | 7 - .../connect/share/ShareConnectionGateway.kt | 184 ++++++++++ .../connect/share/VersionedMinecraftBridge.kt | 108 +++++- .../friend/FriendControlChannelHandler.kt | 17 - .../share/GatewayMinecraftBridgeTest.kt | 182 ++++++++++ .../share/ShareConnectionGatewayTest.kt | 315 ++++++++++++++++++ .../v1_21_11/ConnectShare12111Client.kt | 208 ++++++++---- .../fabric/v1_21_11/Minecraft12111Bridge.kt | 14 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 73 +++- .../assets/connect-share/lang/de_de.json | 5 +- .../assets/connect-share/lang/en_us.json | 5 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 7 + .../fabric/v26_2/ConnectShare262Client.kt | 205 ++++++++---- .../share/fabric/v26_2/Minecraft262Bridge.kt | 14 + .../share/fabric/v26_2/ShareJoinScreen.kt | 73 +++- .../assets/connect-share/lang/de_de.json | 5 +- .../assets/connect-share/lang/en_us.json | 5 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 7 + .../share/fabric/ConnectControlPlane.kt | 56 ++++ .../share/fabric/ConnectShareClient.kt | 41 ++- .../share/fabric/ConnectShareRuntime.kt | 15 +- .../share/fabric/DirectControlPlane.kt | 61 ++++ .../share/fabric/FabricConnectIngress.kt | 6 + .../fabric/FabricSessionAdmissionGate.kt | 7 + .../share/fabric/FabricShareBootstrap.kt | 207 +++++++----- .../share/fabric/FabricShareBrowser.kt | 134 +++++++- .../share/fabric/FriendPairingClient.kt | 88 +++++ .../share/fabric/FriendPresenceMonitor.kt | 58 +++- .../share/fabric/FriendRequestServer.kt | 10 + .../share/fabric/PersistentConnectIngress.kt | 147 ++++++++ .../share/fabric/PersistentDirectIngress.kt | 158 +++++++++ .../share/fabric/ui/FriendsViewModel.kt | 62 +++- .../share/fabric/ConnectControlPlaneTest.kt | 128 +++++++ .../share/fabric/ConnectShareRuntimeTest.kt | 34 +- .../share/fabric/DirectControlPlaneTest.kt | 134 ++++++++ .../fabric/FabricSessionAdmissionGateTest.kt | 35 ++ .../share/fabric/FabricShareBrowserTest.kt | 81 ++++- .../fabric/FriendPairingDirectE2ETest.kt | 253 ++++++++++++++ .../share/fabric/FriendPairingE2ETest.kt | 138 ++++++++ .../share/fabric/FriendPresenceMonitorTest.kt | 107 ++++++ .../fabric/PersistentConnectIngressTest.kt | 130 ++++++++ .../fabric/PersistentDirectIngressTest.kt | 158 +++++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 84 +++++ 43 files changed, 3445 insertions(+), 321 deletions(-) create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ConnectControlPlaneTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt 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 index 8188cfbf5..be28ccc49 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/CapturedServerTransport.kt @@ -5,7 +5,6 @@ import arrow.core.left import arrow.core.right import com.minekube.connect.share.direct.DirectSessionAttributes import com.minekube.connect.share.direct.DirectSessionRegistry -import com.minekube.connect.share.friend.FriendControlChannelRegistry import io.netty.channel.Channel import io.netty.channel.ChannelInitializer import io.netty.channel.EventLoopGroup @@ -37,12 +36,6 @@ object CapturedServerTransport { DirectSessionRegistry.claim(channel.remoteAddress())?.let { channel.attr(DirectSessionAttributes.SESSION).set(it) } - FriendControlChannelRegistry.createHandler()?.let { - channel.pipeline().addLast( - "connect-share-friend-control", - it, - ) - } channel.pipeline().addLast(initializer) } } 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..07dafaa00 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -0,0 +1,184 @@ +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 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.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +class ShareConnectionGateway private constructor( + private val friendServer: FriendControlServer, +) : CommonPlatformInjector(), AutoCloseable { + private val activeMinecraft = + AtomicReference?>(null) + private val closed = AtomicBoolean() + private val localEventLoop: EventLoopGroup = DefaultEventLoopGroup( + 1, + DefaultThreadFactory("Connect Share gateway local"), + ) + private val directEventLoop: EventLoopGroup = NioEventLoopGroup( + 1, + 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_DISPATCH_HANDLER, + MinecraftDispatchHandler(activeMinecraft), + ) + } + } + + private fun closeAfterFailedBind() { + runCatching { closeChannel(localChannel) } + localChannel = null + shutdownEventLoop(directEventLoop) + shutdownEventLoop(localEventLoop) + } + + 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_INITIALIZER, initializer) + pipeline.fireChannelRead(message) + } + } + + companion object { + fun bind(friendServer: FriendControlServer): + ShareConnectionGateway = + ShareConnectionGateway(friendServer) + + 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_INITIALIZER = + "connect-share-minecraft-initializer" + } +} 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 index 8f0a35bf5..c744ce344 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -16,11 +16,34 @@ import io.netty.util.concurrent.DefaultThreadFactory import java.net.InetSocketAddress import java.net.SocketAddress -open class VersionedMinecraftBridge( +open class VersionedMinecraftBridge private constructor( private val transport: MinecraftVersionTransport, - private val localBinder: LocalShareChannelBinder, + 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 @@ -30,26 +53,54 @@ open class VersionedMinecraftBridge( 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 = {}, ) - local = localBinder.bind(published.childInitializer) - validateLocal(local).fold( - ifLeft = { failure -> throw IllegalStateException(failure.safeMessage) }, - ifRight = {}, - ) - published.addLocalListener(local) - localAdded = true + 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) + 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, local, admission) + val acquired = ActiveTransport( + published = published, + local = local, + localAdded = localAdded, + gatewayLease = gatewayLease, + admission = admission, + ) active = acquired - serverSocketAddress = local.address + serverSocketAddress = connectAddress LocalShareTarget( - address = local.address, - directAddress = published.address, + address = connectAddress, + directAddress = directAddress, ) { close(acquired) } @@ -58,6 +109,9 @@ open class VersionedMinecraftBridge( cleanup = releaseAfter(cleanup) { admission?.close() } + cleanup = releaseAfter(cleanup) { + gatewayLease?.close() + } if (localAdded) { cleanup = releaseAfter(cleanup) { published.removeLocalListener(checkNotNull(local)) @@ -115,13 +169,27 @@ open class VersionedMinecraftBridge( } } + 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 local: LocalShareChannel?, + private val localAdded: Boolean, + private val gatewayLease: AutoCloseable?, private val admission: AutoCloseable?, ) { private var admissionStopped = false - private var localClosed = false + private var routeClosed = false private var publishedClosed = false fun stopAdmission(primary: Throwable?): Throwable? { @@ -135,11 +203,17 @@ open class VersionedMinecraftBridge( } fun closeLocal(primary: Throwable?): Throwable? { - if (localClosed) { + if (routeClosed) { return primary } - localClosed = true + routeClosed = true var failure = releaseAfter(primary) { + gatewayLease?.close() + } + if (!localAdded || local == null) { + return failure + } + failure = releaseAfter(failure) { published.removeLocalListener(local) } failure = releaseAfter(failure) { 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 index 9bb1f98e7..bc4319b45 100644 --- 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 @@ -6,7 +6,6 @@ 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.ChannelHandler import io.netty.channel.ChannelHandlerContext import io.netty.channel.ChannelInboundHandlerAdapter import io.netty.util.ReferenceCountUtil @@ -169,19 +168,3 @@ class FriendControlChannelHandler( ) } } - -object FriendControlChannelRegistry { - private val installed = AtomicReference() - - fun install(server: FriendControlServer): AutoCloseable { - check(installed.compareAndSet(null, server)) { - "A friend control server is already installed" - } - return AutoCloseable { - installed.compareAndSet(server, null) - } - } - - fun createHandler(): ChannelHandler? = - installed.get()?.let(::FriendControlChannelHandler) -} 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..71593ff55 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt @@ -0,0 +1,182 @@ +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.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), + ) + } + }, + ) + } + } + 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 + } + } + + private companion object { + val CONTROL_REQUEST = com.minekube.connect.share.friend + .FriendControlRequest( + requestId = java.util.UUID.fromString( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + displayName = "ordinary", + invitation = "minekube://share/ordinary", + ) + val MINECRAFT_BYTES = + com.minekube.connect.share.friend.FriendControlWire + .encodeRequest( + protocolVersion = 1_075, + serverAddress = "ordinary-minecraft", + request = CONTROL_REQUEST, + ).copyOf().also { bytes -> + val port = + com.minekube.connect.share.friend + .FriendControlWire + .CONTROL_HANDSHAKE_PORT + val high = port ushr 8 + val low = port and 0xff + val index = bytes.indices.first { + it + 1 < bytes.size && + bytes[it].toInt() and 0xff == high && + bytes[it + 1].toInt() and 0xff == low + } + bytes[index] = (25_565 ushr 8).toByte() + bytes[index + 1] = 25_565.toByte() + } + } +} 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..11e144686 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -0,0 +1,315 @@ +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.FriendControlRequest +import com.minekube.connect.share.friend.FriendControlResponse +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 java.io.ByteArrayOutputStream +import java.net.Socket +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ShareConnectionGatewayTest { + @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( + protocolVersion = 1_075, + serverAddress = "connect-share", + 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 world = 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 + 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), + ) + } + + 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( + protocolVersion = 1_075, + serverAddress = "friend-control", + 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 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 = + FriendControlWire.encodeRequest( + protocolVersion = 1_075, + serverAddress = "ordinary-minecraft", + request = REQUEST, + ).copyOf().also { bytes -> + val controlHigh = + FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 + val controlLow = + FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff + val portIndex = bytes.indices.first { + it + 1 < bytes.size && + bytes[it].toInt() and 0xff == controlHigh && + bytes[it + 1].toInt() and 0xff == controlLow + } + bytes[portIndex] = (25_565 ushr 8).toByte() + bytes[portIndex + 1] = 25_565.toByte() + } + } +} 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 index 6237e7c3d..ca606795b 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -10,15 +11,25 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardReceiver import com.minekube.connect.share.fabric.FriendOnlineTracker import com.minekube.connect.share.fabric.FriendPresenceMonitor -import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.MinecraftStatusProbe import com.minekube.connect.share.friend.FriendStore +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.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 @@ -32,87 +43,149 @@ import net.minecraft.network.chat.Component class ConnectShare12111Client : ClientModInitializer { override fun onInitializeClient() { val client = Minecraft.getInstance() - val dispatcher = client.asCoroutineDispatcher() - val scope = CoroutineScope(SupervisorJob() + dispatcher) + 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 minecraftVersion = + SharedConstants.getCurrentVersion().name() + val minecraftProtocolVersion = + SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) - val remotePresence = FriendPresenceMonitor(friendStore) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + probe = statusProbe, + directProbe = { friend -> + browserReference.get()?.probeLan( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ownConnectAddress = + ConnectShareClient::connectPublicAddress, + ) scope.launch { while (isActive) { remotePresence.refresh() delay(PRESENCE_REFRESH_MILLIS) } } - val installation = FabricShareBootstrap.create( - scope = scope, - dataDirectory = dataDirectory, - minecraftVersion = SharedConstants.getCurrentVersion().name(), - minecraftProtocolVersion = SharedConstants.getProtocolVersion(), - worldAvailable = client.hasSingleplayerServer(), - friendStore = friendStore, - playerCount = { - client.singleplayerServer?.playerList?.playerCount ?: 0 - }, - worldDisplayName = { - client.singleplayerServer?.worldData?.levelName - ?: "Minecraft world" - }, - bridgeFactory = { admission, admissionScope, approvedJoins -> - Minecraft12111Bridge { - FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission( + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + minecraftProtocolVersion = minecraftProtocolVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + bridgeFactory = { admission, + admissionScope, approvedJoins, - ), - scope = admissionScope, - ) - } - }, - screens = { parent, active -> - val parentScreen = parent as Screen - client.execute { - client.setScreen( - if (active) { - ShareStatusScreen(parentScreen) - } else { - ShareSetupScreen(parentScreen) - }, + 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 -> + val parentScreen = parent as Screen + client.execute { + client.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, ) - } - }, - guestScreens = { parent -> - val parentScreen = parent as Screen - client.execute { - client.setScreen( - ShareJoinScreen( - parent = parentScreen, - friends = FriendsViewModel( - friendStore, - ), - browser = FabricShareBrowser(dataDirectory), - remotePresence = remotePresence, - ), + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", ) } - }, - ) - FriendCardNetworking.install( - scope = scope, - issuer = installation.friendCardIssuer, - receiver = installation.friendCardReceiver, - approvedJoins = installation.approvedJoins, - ) - ConnectShareClient.install(installation) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } val admissionNotifications = NewAdmissionTracker() val friendNotifications = FriendOnlineTracker() val admissionToastId = SystemToast.SystemToastId() val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + val installation = + installationReference.get() + ?: return@register + val server = minecraft.singleplayerServer + val worldAvailable = minecraft.hasSingleplayerServer() + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) ConnectShareClient.integratedWorldChanged( - minecraft.hasSingleplayerServer(), - minecraft.singleplayerServer, + worldAvailable, + server, ) ConnectShareClient.guestConnectionChanged( minecraft.connection != null, @@ -157,12 +230,21 @@ class ConnectShare12111Client : ClientModInitializer { } } ClientLifecycleEvents.CLIENT_STOPPING.register { - ConnectShareClient.shutdown() - scope.cancel() + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } } } private companion object { const val PRESENCE_REFRESH_MILLIS = 30_000L + val LOGGER: Logger = Logger.getLogger("Connect") } } 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 index 6b4e7cc23..49efb333c 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareCha 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 @@ -39,6 +40,19 @@ class Minecraft12111Bridge internal constructor( ) } +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 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 index 879901f31..ed76f443d 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor 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.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel @@ -55,7 +56,6 @@ class ShareJoinScreen( private var joining = false private var joiningPeerId: String? = null private var reciprocalPairing = false - private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE private var requestOperationInProgress = false @@ -72,6 +72,9 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) fingerprint = currentFingerprint() nameBox = null invitationBox = null @@ -89,6 +92,9 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) val next = currentFingerprint() if (next != fingerprint) { rebuildWidgets() @@ -119,9 +125,6 @@ class ShareJoinScreen( override fun removed() { scope?.cancel() scope = null - if (!transferred) { - browser.close() - } super.removed() } @@ -140,11 +143,16 @@ class ShareJoinScreen( ) val state = friends.state.value - val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val incoming = state.incomingRequests.take( + MAX_VISIBLE_RELATIONSHIPS, + ) + val outgoing = state.outgoingRequests.take( + MAX_VISIBLE_RELATIONSHIPS - incoming.size, + ) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - outgoing.size, + MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, ) - if (outgoing.isEmpty() && saved.isEmpty()) { + if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -152,8 +160,39 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - outgoing.forEachIndexed { index, request -> + incoming.forEachIndexed { index, request -> val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.incoming_request", + request.displayName, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + outgoing.forEachIndexed { index, request -> + val y = 58 + (incoming.size + index) * 26 val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( @@ -192,7 +231,8 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (outgoing.size + index) * 26 + val y = + 58 + (incoming.size + outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -569,6 +609,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ).fold( ifLeft = ::joinFailed, ifRight = ::connect, @@ -633,6 +675,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { @@ -771,10 +815,7 @@ class ShareJoinScreen( ) } if (target is GuestJoinTarget.Direct) { - ConnectShareClient.holdGuestDirect(target, browser) - transferred = true - } else { - browser.close() + ConnectShareClient.holdGuestDirect(target) } val state = friends.state.value val joiningFriend = state.friends.firstOrNull { @@ -849,6 +890,12 @@ class ShareJoinScreen( ) } + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + private fun outgoingRequestLabel( displayName: String, deliveryState: RequestDeliveryState?, 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 index c9feb35ec..e85b94ae9 100644 --- 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 @@ -53,17 +53,18 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder sein Connect-Endpunkt ist nicht 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.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", + "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", 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 index 1f1649285..fe750872d 100644 --- 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 @@ -53,17 +53,18 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or their Connect endpoint unavailable.", "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", 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 index 589eca7b3..84535e097 100644 --- 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 @@ -46,6 +46,10 @@ class Fabric12111ArtifactTest { "\"connect_share.friends.outgoing_request\": " + "\"Request to %s\"" in language, ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %s\"" in language, + ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in language, @@ -86,6 +90,9 @@ class Fabric12111ArtifactTest { ) assertTrue("sendRequest" 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) } 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 index 2b43f0276..d4a6fada3 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -10,15 +11,25 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardReceiver import com.minekube.connect.share.fabric.FriendOnlineTracker import com.minekube.connect.share.fabric.FriendPresenceMonitor -import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.MinecraftStatusProbe import com.minekube.connect.share.friend.FriendStore +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.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 @@ -32,88 +43,149 @@ import net.minecraft.network.chat.Component class ConnectShare262Client : ClientModInitializer { override fun onInitializeClient() { val client = Minecraft.getInstance() + val clientDispatcher = client.asCoroutineDispatcher() val scope = CoroutineScope( - SupervisorJob() + client.asCoroutineDispatcher(), + 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 minecraftVersion = + SharedConstants.getCurrentVersion().name() + val minecraftProtocolVersion = + SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) - val remotePresence = FriendPresenceMonitor(friendStore) + val browserReference = + AtomicReference() + val statusProbe = MinecraftStatusProbe() + val remotePresence = FriendPresenceMonitor( + store = friendStore, + probe = statusProbe, + directProbe = { friend -> + browserReference.get()?.probeLan( + friend = friend, + authMode = DirectP2pAuthMode.OFFLINE, + probe = statusProbe, + ) + }, + ownConnectAddress = + ConnectShareClient::connectPublicAddress, + ) scope.launch { while (isActive) { remotePresence.refresh() delay(PRESENCE_REFRESH_MILLIS) } } - val installation = FabricShareBootstrap.create( - scope = scope, - dataDirectory = dataDirectory, - minecraftVersion = SharedConstants.getCurrentVersion().name(), - minecraftProtocolVersion = SharedConstants.getProtocolVersion(), - worldAvailable = client.hasSingleplayerServer(), - friendStore = friendStore, - playerCount = { - client.singleplayerServer?.playerList?.playerCount ?: 0 - }, - worldDisplayName = { - client.singleplayerServer?.worldData?.levelName - ?: "Minecraft world" - }, - bridgeFactory = { admission, admissionScope, approvedJoins -> - Minecraft262Bridge { - FabricLocalLoginAdmissionGate( - admission = FabricLocalLoginAdmission( + val bootstrapJob = scope.launch(Dispatchers.IO) { + try { + val installation = FabricShareBootstrap.create( + scope = scope, + dataDirectory = dataDirectory, + minecraftVersion = minecraftVersion, + minecraftProtocolVersion = minecraftProtocolVersion, + worldAvailable = worldAvailableSnapshot.get(), + friendStore = friendStore, + playerCount = playerCountSnapshot::get, + worldDisplayName = worldNameSnapshot::get, + bridgeFactory = { admission, + admissionScope, approvedJoins, - ), - scope = admissionScope, + 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 -> + val parentScreen = parent as Screen + client.execute { + client.gui.setScreen( + ShareJoinScreen( + parent = parentScreen, + friends = + ConnectShareClient.friendsViewModel(), + browser = browser, + remotePresence = remotePresence, + ), + ) + } + }, + ) + browserReference.set(installation.browser) + withContext(clientDispatcher) { + FriendCardNetworking.install( + scope = scope, + issuer = installation.friendCardIssuer, + receiver = installation.friendCardReceiver, + approvedJoins = installation.approvedJoins, ) - } - }, - screens = { parent, active -> - val parentScreen = parent as Screen - client.execute { - client.gui.setScreen( - if (active) { - ShareStatusScreen(parentScreen) - } else { - ShareSetupScreen(parentScreen) - }, + ConnectShareClient.install(installation) + installationReference.set(installation) + LOGGER.info( + "Connect Share friend gateway is ready", ) } - }, - guestScreens = { parent -> - val parentScreen = parent as Screen - client.execute { - client.gui.setScreen( - ShareJoinScreen( - parent = parentScreen, - friends = FriendsViewModel( - friendStore, - ), - browser = FabricShareBrowser(dataDirectory), - remotePresence = remotePresence, - ), - ) - } - }, - ) - FriendCardNetworking.install( - scope = scope, - issuer = installation.friendCardIssuer, - receiver = installation.friendCardReceiver, - approvedJoins = installation.approvedJoins, - ) - ConnectShareClient.install(installation) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + LOGGER.log( + Level.SEVERE, + "Connect Share initialization failed", + failure, + ) + } + } val admissionNotifications = NewAdmissionTracker() val friendNotifications = FriendOnlineTracker() val admissionToastId = SystemToast.SystemToastId() val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> + val installation = + installationReference.get() + ?: return@register + val server = minecraft.singleplayerServer + val worldAvailable = minecraft.hasSingleplayerServer() + worldAvailableSnapshot.set(worldAvailable) + playerCountSnapshot.set( + server?.playerList?.playerCount ?: 0, + ) + worldNameSnapshot.set( + server?.worldData?.levelName ?: "Minecraft world", + ) ConnectShareClient.integratedWorldChanged( - minecraft.hasSingleplayerServer(), - minecraft.singleplayerServer, + worldAvailable, + server, ) ConnectShareClient.guestConnectionChanged( minecraft.connection != null, @@ -158,12 +230,21 @@ class ConnectShare262Client : ClientModInitializer { } } ClientLifecycleEvents.CLIENT_STOPPING.register { - ConnectShareClient.shutdown() - scope.cancel() + scope.launch(Dispatchers.IO) { + bootstrapJob.cancelAndJoin() + try { + if (installationReference.get() != null) { + ConnectShareClient.shutdown() + } + } finally { + scope.cancel() + } + } } } private companion object { const val PRESENCE_REFRESH_MILLIS = 30_000L + val LOGGER: Logger = Logger.getLogger("Connect") } } 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 index 427ebe151..91b054fa0 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.LocalShareChannelBinder as CommonLocalShareCha 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 @@ -36,6 +37,19 @@ class Minecraft262Bridge internal constructor( ) } +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 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 index d109173a3..33c5d796a 100644 --- 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 @@ -5,6 +5,7 @@ import com.minekube.connect.share.fabric.FabricShareBrowser import com.minekube.connect.share.fabric.FriendCardExchangeConsent import com.minekube.connect.share.fabric.FriendPresenceMonitor 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.fabric.ui.FriendSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel @@ -55,7 +56,6 @@ class ShareJoinScreen( private var joining = false private var joiningPeerId: String? = null private var reciprocalPairing = false - private var transferred = false private var removeConfirmation = false private var friendLinkState = FriendLinkState.IDLE private var requestOperationInProgress = false @@ -72,6 +72,9 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) fingerprint = currentFingerprint() nameBox = null invitationBox = null @@ -89,6 +92,9 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateIncoming( + ConnectShareClient.viewModel().state.value.pendingAdmissions, + ) val next = currentFingerprint() if (next != fingerprint) { rebuildWidgets() @@ -119,9 +125,6 @@ class ShareJoinScreen( override fun removed() { scope?.cancel() scope = null - if (!transferred) { - browser.close() - } super.removed() } @@ -140,11 +143,16 @@ class ShareJoinScreen( ) val state = friends.state.value - val outgoing = state.outgoingRequests.take(MAX_VISIBLE_RELATIONSHIPS) + val incoming = state.incomingRequests.take( + MAX_VISIBLE_RELATIONSHIPS, + ) + val outgoing = state.outgoingRequests.take( + MAX_VISIBLE_RELATIONSHIPS - incoming.size, + ) val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - outgoing.size, + MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, ) - if (outgoing.isEmpty() && saved.isEmpty()) { + if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( centered( Component.translatable("connect_share.friends.empty"), @@ -152,8 +160,39 @@ class ShareJoinScreen( ).setMaxWidth(CONTENT_WIDTH), ) } - outgoing.forEachIndexed { index, request -> + incoming.forEachIndexed { index, request -> val y = 58 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + Component.translatable( + "connect_share.friends.incoming_request", + request.displayName, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + outgoing.forEachIndexed { index, request -> + val y = 58 + (incoming.size + index) * 26 val deliveryState = requestStates[request.peerId] addRenderableWidget( StringWidget( @@ -192,7 +231,8 @@ class ShareJoinScreen( ) } saved.forEachIndexed { index, friend -> - val y = 58 + (outgoing.size + index) * 26 + val y = + 58 + (incoming.size + outgoing.size + index) * 26 addRenderableWidget( Button.builder(friendLabel(friend)) { joinSaved(friend.peerId) @@ -569,6 +609,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ).fold( ifLeft = ::joinFailed, ifRight = ::connect, @@ -633,6 +675,8 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), + ownConnectAddress = + ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { @@ -770,10 +814,7 @@ class ShareJoinScreen( ) } if (target is GuestJoinTarget.Direct) { - ConnectShareClient.holdGuestDirect(target, browser) - transferred = true - } else { - browser.close() + ConnectShareClient.holdGuestDirect(target) } val state = friends.state.value val joiningFriend = state.friends.firstOrNull { @@ -848,6 +889,12 @@ class ShareJoinScreen( ) } + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + private fun outgoingRequestLabel( displayName: String, deliveryState: RequestDeliveryState?, 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 index c9feb35ec..e85b94ae9 100644 --- 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 @@ -53,17 +53,18 @@ "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.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 muss gerade eine Welt teilen.", + "connect_share.friends.request_failed": "Die Anfrage konnte nicht zugestellt werden. Dein Freund ist möglicherweise offline oder sein Connect-Endpunkt ist nicht 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.add": "Freund hinzufügen", - "connect_share.friends.add_description": "Füge den Link deines Freundes ein und sende eine Anfrage. Die Person muss gerade eine Welt teilen.", + "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", 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 index 1f1649285..fe750872d 100644 --- 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 @@ -53,17 +53,18 @@ "connect_share.friends.copy_my_link_failed": "Could not copy friend link", "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "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 must be sharing a world.", + "connect_share.friends.request_failed": "Request could not be delivered. Your friend may be offline or their Connect endpoint unavailable.", "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", "connect_share.friends.add": "Add friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They must be sharing a world to receive it.", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", "connect_share.friends.name_hint": "Name of the person who sent the link", "connect_share.friends.save": "Save friend", 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 index 819fb224b..6fe5b64ac 100644 --- 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 @@ -46,6 +46,10 @@ class Fabric262ArtifactTest { "\"connect_share.friends.outgoing_request\": " + "\"Request to %s\"" in language, ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %s\"" in language, + ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in language, @@ -88,6 +92,9 @@ class Fabric262ArtifactTest { ) assertTrue("sendRequest" 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) } 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..a92886171 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ConnectControlPlane.kt @@ -0,0 +1,56 @@ +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() + } + } +} 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 index a9f13c2e9..5cb772d48 100644 --- 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 @@ -1,24 +1,32 @@ 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 fun interface ConnectShareScreenFactory { fun open(parent: Any, active: Boolean) } fun interface ConnectShareGuestScreenFactory { - fun open(parent: Any) + fun open(parent: Any, browser: FabricShareBrowser) } data class ConnectShareInstallation( val viewModel: ShareViewModel, + val friendsViewModel: FriendsViewModel, val runtime: ConnectShareRuntime, val friendCardIssuer: FriendCardIssuer, val friendCardReceiver: FriendCardReceiver, val friendRequestClient: FriendRequestClient, + val friendPairingClient: FriendPairingClient, val approvedJoins: ApprovedJoinTracker, - val friendControlLease: AutoCloseable, + val controlPlane: ConnectControlPlane, + val directControlPlane: DirectControlPlane, + val browser: FabricShareBrowser, + val gateway: ShareConnectionGateway, + val ownConnectAddress: String, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -56,14 +64,15 @@ object ConnectShareClient { @JvmStatic fun openJoinScreen(parent: Any) { - installation?.guestScreens?.open(parent) + installation?.let { installed -> + installed.guestScreens.open(parent, installed.browser) + } } fun holdGuestDirect( target: GuestJoinTarget.Direct, - browser: FabricShareBrowser, ) { - guestLease.hold(target, browser) + guestLease.hold(target, NOOP_CLOSE) } @JvmStatic @@ -75,6 +84,10 @@ object ConnectShareClient { fun viewModel(): ShareViewModel = checkNotNull(installation).viewModel + @JvmStatic + fun friendsViewModel(): FriendsViewModel = + checkNotNull(installation).friendsViewModel + @JvmStatic fun friendCardIssuer(): FriendCardIssuer = checkNotNull(installation).friendCardIssuer @@ -87,6 +100,14 @@ object ConnectShareClient { fun friendRequestClient(): FriendRequestClient = checkNotNull(installation).friendRequestClient + @JvmStatic + fun friendPairingClient(): FriendPairingClient = + checkNotNull(installation).friendPairingClient + + @JvmStatic + fun connectPublicAddress(): String? = + installation?.ownConnectAddress + @JvmStatic fun armFriendCardExchange(peerId: String) { friendCardConsent.arm(peerId) @@ -105,13 +126,17 @@ object ConnectShareClient { } @JvmStatic - fun shutdown() { + suspend fun shutdown() { friendCardConsent.cancel() guestLease.close() installation?.let { installed -> - installed.friendControlLease.close() installed.runtime.shutdown() + installed.directControlPlane.shutdown() + installed.controlPlane.shutdown() + installed.browser.close() + installed.gateway.close() } + installation = null } private fun isShareActive(): Boolean = when ( @@ -127,6 +152,8 @@ object ConnectShareClient { ShareState.Stopping, -> true } + + private val NOOP_CLOSE = AutoCloseable {} } internal class GuestConnectionLease( 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 index df8084167..0a360d666 100644 --- 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 @@ -1,16 +1,19 @@ package com.minekube.connect.share.fabric +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart +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() @@ -37,7 +40,7 @@ class ConnectShareRuntime( worldAvailabilityChanged(worldAvailable) return } - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(lifecycleDispatcher) { lifecycle.withLock { if (transition.stopPrevious) { stopShare() @@ -50,15 +53,15 @@ class ConnectShareRuntime( } } - fun shutdown() { + suspend fun shutdown() { val shouldStop = synchronized(lock) { (currentWorldIdentity != null).also { currentWorldIdentity = null } } - worldAvailabilityChanged(false) - if (shouldStop) { - scope.launch(start = CoroutineStart.UNDISPATCHED) { + withContext(lifecycleDispatcher) { + worldAvailabilityChanged(false) + if (shouldStop) { lifecycle.withLock { stopShare() } 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..d742f549a --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/DirectControlPlane.kt @@ -0,0 +1,61 @@ +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() + } + } +} 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 index 07d36bf94..f6b7d9647 100644 --- 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 @@ -35,6 +35,7 @@ class FabricConnectIngress private constructor( private val admission: AdmissionController, private val approvedJoins: ApprovedJoinTracker, private val scope: CoroutineScope, + private val worldAvailable: () -> Boolean, private val runtimeFactory: FabricConnectRuntimeFactory, ) : ConnectShareIngress { constructor( @@ -45,11 +46,13 @@ class FabricConnectIngress private constructor( 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, @@ -79,6 +82,7 @@ class FabricConnectIngress private constructor( admission, scope, approvedJoins, + worldAvailable, ) val runtime = try { runtimeFactory.start(identity, target, gate) @@ -107,11 +111,13 @@ class FabricConnectIngress private constructor( runtimeFactory: FabricConnectRuntimeFactory, approvedJoins: ApprovedJoinTracker = ApprovedJoinTracker(), + worldAvailable: () -> Boolean = { true }, ) = FabricConnectIngress( dataDirectory = dataDirectory, admission = admission, approvedJoins = approvedJoins, scope = scope, + worldAvailable = worldAvailable, runtimeFactory = runtimeFactory, ) } 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 index 7637b0cb5..a3176ce52 100644 --- 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 @@ -27,6 +27,7 @@ class FabricSessionAdmissionGate( private val scope: CoroutineScope, private val approvedJoins: ApprovedJoinTracker = ApprovedJoinTracker(), + private val worldAvailable: () -> Boolean = { true }, ) : SessionAdmissionGate { private val stopped = AtomicBoolean() private val active = ConcurrentHashMap, Job>() @@ -39,6 +40,11 @@ class FabricSessionAdmissionGate( SessionAdmissionDecision.allow(), ) } + if (!worldAvailable()) { + return CompletableFuture.completedFuture( + SessionAdmissionDecision.deny(NO_SHARED_WORLD), + ) + } if (proposal.session.auth.passthrough) { return CompletableFuture.completedFuture( SessionAdmissionDecision.deferToLocalLogin(), @@ -137,6 +143,7 @@ class FabricSessionAdmissionGate( 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" } } 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 index a95e58d88..f41a07608 100644 --- 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 @@ -3,13 +3,16 @@ 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.VersionedMinecraftBridge import com.minekube.connect.share.admission.AdmissionController import com.minekube.connect.share.admission.AdmissionIdentity 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.friend.FriendStore -import com.minekube.connect.share.friend.FriendControlChannelRegistry import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -25,7 +28,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient object FabricShareBootstrap { - fun create( + suspend fun create( scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, @@ -39,6 +42,7 @@ object FabricShareBootstrap { AdmissionController, CoroutineScope, ApprovedJoinTracker, + ShareConnectionGateway, ) -> VersionedMinecraftBridge, screens: ConnectShareScreenFactory, guestScreens: ConnectShareGuestScreenFactory, @@ -72,11 +76,6 @@ object FabricShareBootstrap { }.getOrDefault(false) }, ) - val bridge = bridgeFactory( - admission, - scope, - approvedJoins, - ) val identityStore = EndpointIdentityStore( directory = dataDirectory, environment = environment, @@ -95,63 +94,14 @@ object FabricShareBootstrap { watchUrl = watchHttpUrl(environment), timeout = 10.seconds, ) - val ingress = FabricConnectIngress( - dataDirectory = dataDirectory, - platformInjector = bridge, - logger = logger, - platformUtils = FabricPlatformUtils( - minecraftVersion = minecraftVersion, - playerCount = playerCount, - ), - admission = admission, - approvedJoins = approvedJoins, - scope = scope, - ) - val directIngress = FabricDirectShareIngress( - dataDirectory = dataDirectory, - displayName = worldDisplayName, - ) - val coordinator = ShareCoordinator( - bridge = bridge, - ingress = ingress, - identityProvider = identityStore::currentOrCreate, - admission = admission, - directIngress = directIngress, - failureReporter = logger::warn, - ) - val viewModel = ShareViewModel( - scope = scope, - shareState = coordinator.state, - pendingAdmissions = admission.pending, - initialWorldAvailable = worldAvailable, - initialShareWithFriendsEnabled = - initialPreferences.shareWithFriends, - persistShareWithFriendsEnabled = { enabled -> - preferencesStore.save( - SharePreferences(shareWithFriends = enabled), - ) - }, - identityActions = StoredEndpointIdentityUiActions( - store = identityStore, - validator = validator, - ), - startShare = coordinator::start, - stopShare = coordinator::stop, - answerAdmission = admission::answer, - ) - viewModelReference.set(viewModel) - val runtime = ConnectShareRuntime( - scope = scope, - stopShare = { - coordinator.worldReplaced() - }, - resumeShare = viewModel::resumeIfEnabled, - worldAvailabilityChanged = viewModel::setWorldAvailable, - ) + val endpointIdentity = identityStore.currentOrCreate() + val ownConnectAddress = + "${endpointIdentity.endpoint}.play.minekube.net" val friendCardIssuer = FriendCardIssuer(dataDirectory) { - "${identityStore.currentOrCreate().endpoint}.play.minekube.net" + ownConnectAddress } val friendCardReceiver = FriendCardReceiver(friendStore) + val friendsViewModel = FriendsViewModel(friendStore) val friendRequestServer = FriendRequestServer( scope = scope, admission = admission, @@ -159,21 +109,128 @@ object FabricShareBootstrap { receiver = friendCardReceiver, friendStore = friendStore, ) - val friendControlLease = - FriendControlChannelRegistry.install(friendRequestServer) - return ConnectShareInstallation( - viewModel = viewModel, - runtime = runtime, - friendCardIssuer = friendCardIssuer, - friendCardReceiver = friendCardReceiver, - friendRequestClient = FriendRequestClient( + val gateway = ShareConnectionGateway.bind(friendRequestServer) + var browser: FabricShareBrowser? = null + try { + val activeBrowser = FabricShareBrowser(dataDirectory) + 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( + FabricDirectShareIngress( + dataDirectory = dataDirectory, + displayName = worldDisplayName, + ), + ) + val coordinator = ShareCoordinator( + bridge = bridge, + ingress = ingress, + identityProvider = identityStore::currentOrCreate, + admission = admission, + directIngress = directIngress, + failureReporter = logger::warn, + ) + val viewModel = ShareViewModel( + scope = scope, + shareState = coordinator.state, + pendingAdmissions = admission.pending, + initialWorldAvailable = worldAvailable, + initialShareWithFriendsEnabled = + initialPreferences.shareWithFriends, + persistShareWithFriendsEnabled = { enabled -> + preferencesStore.save( + SharePreferences(shareWithFriends = enabled), + ) + }, + identityActions = StoredEndpointIdentityUiActions( + store = identityStore, + validator = validator, + ), + startShare = coordinator::start, + stopShare = coordinator::stop, + answerAdmission = admission::answer, + ) + viewModelReference.set(viewModel) + val runtime = ConnectShareRuntime( + scope = scope, + stopShare = { + coordinator.worldReplaced() + }, + resumeShare = viewModel::resumeIfEnabled, + worldAvailabilityChanged = viewModel::setWorldAvailable, + ) + val friendRequestClient = FriendRequestClient( minecraftProtocolVersion, - ), - approvedJoins = approvedJoins, - friendControlLease = friendControlLease, - screens = screens, - guestScreens = guestScreens, - ) + ) + val friendPairingClient = FriendPairingClient( + store = friendStore, + issuer = friendCardIssuer, + receiver = friendCardReceiver, + requestClient = friendRequestClient, + ) + val controlPlane = ConnectControlPlane( + scope = scope, + ingress = ingress, + identity = { endpointIdentity }, + target = gateway.serverSocketAddress, + failureReporter = logger::warn, + ).also(ConnectControlPlane::start) + val directControlPlane = DirectControlPlane( + scope = scope, + ingress = directIngress, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ), + target = gateway.directAddress, + connectAddress = { ownConnectAddress }, + failureReporter = logger::warn, + ).also(DirectControlPlane::start) + return ConnectShareInstallation( + viewModel = viewModel, + friendsViewModel = friendsViewModel, + runtime = runtime, + friendCardIssuer = friendCardIssuer, + friendCardReceiver = friendCardReceiver, + friendRequestClient = friendRequestClient, + friendPairingClient = friendPairingClient, + approvedJoins = approvedJoins, + controlPlane = controlPlane, + directControlPlane = directControlPlane, + browser = activeBrowser, + gateway = gateway, + ownConnectAddress = ownConnectAddress, + screens = screens, + guestScreens = guestScreens, + ) + } catch (failure: Throwable) { + browser?.close() + gateway.close() + throw failure + } } internal fun watchHttpUrl(environment: Map) = 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 index 14affbdcd..3ec45f453 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -86,17 +87,24 @@ sealed interface GuestJoinFailure { 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( @@ -105,6 +113,7 @@ class FabricShareBrowser private constructor( ), now = Instant::now, ioDispatcher = Dispatchers.IO, + routeReporter = LOGGER::info, ) private val mutableDiscovered = @@ -159,29 +168,46 @@ class FabricShareBrowser private constructor( when (route) { ShareRoute.DIRECT_LAN -> { val address = effectiveLanAddress ?: continue - openDirect( + val direct = openDirect( route, address, invitation, authMode, LAN_TIMEOUT, - )?.let { return@withContext it.right() } + ) + 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) { - openDirect( + attempted = true + val direct = openDirect( route, address, invitation, authMode, INTERNET_TIMEOUT, - )?.let { return@withContext it.right() } + ) + 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() } } @@ -194,24 +220,63 @@ class FabricShareBrowser private constructor( suspend fun join( friend: SavedFriend, authMode: DirectP2pAuthMode, + ownConnectAddress: String? = null, ): Either = withContext(ioDispatcher) { - matchingLanShare(friend)?.let { discovered -> - openDirect( + 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, - )?.let { return@withContext it.right() } + ) + if (direct != null) { + reportRoute(ROUTE_DIRECT_LAN) + return@withContext direct.right() + } + reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) + } else { + reportRoute(ROUTE_DIRECT_LAN_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 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() + } + } + override fun close() { if (closed.compareAndSet(false, true)) { node.close() @@ -301,20 +366,73 @@ class FabricShareBrowser private constructor( 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, - ) = FabricShareBrowser(node, now, ioDispatcher) + 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 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..236f1fca1 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt @@ -0,0 +1,88 @@ +package com.minekube.connect.share.fabric + +import arrow.core.Either +import arrow.core.raise.either +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() + val hostCard = requestClient.exchange( + target = target, + request = FriendControlRequest( + requestId = UUID.randomUUID(), + displayName = senderDisplayName, + invitation = senderCard, + ), + onReceived = onReceived, + ).mapLeft(FriendPairingFailure::Delivery).bind() + receiver.receive( + invitation = hostCard, + displayName = friendDisplayName, + authenticatedMinecraftUuid = null, + 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 index c82545422..c3d7a6cba 100644 --- 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 @@ -1,12 +1,16 @@ 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, @@ -14,6 +18,7 @@ data class RemoteFriendPresence( val online: Boolean, val description: String? = null, val notifyWhenOnline: Boolean, + val route: ShareRoute? = null, ) class FriendOnlineTracker { @@ -37,13 +42,22 @@ class FriendOnlineTracker { class FriendPresenceMonitor private constructor( private val friends: () -> List, private val probe: FriendStatusProbe, + private val directProbe: suspend (SavedFriend) -> ServerPresence?, + private val ownConnectAddress: () -> String?, + private val ioDispatcher: CoroutineDispatcher, ) { constructor( store: FriendStore, probe: FriendStatusProbe = MinecraftStatusProbe(), + directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, + ownConnectAddress: () -> String? = { null }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : this( friends = store::all, probe = probe, + directProbe = directProbe, + ownConnectAddress = ownConnectAddress, + ioDispatcher = ioDispatcher, ) private val mutableState = @@ -52,18 +66,34 @@ class FriendPresenceMonitor private constructor( val state: StateFlow> = mutableState.asStateFlow() - suspend fun refresh() { + suspend fun refresh() = withContext(ioDispatcher) { val saved = runCatching(friends) .getOrDefault(emptyList()) .take(MAX_PROBED_FRIENDS) + val ownAddress = runCatching(ownConnectAddress).getOrNull() val results = saved.parMap( - context = Dispatchers.IO, + context = ioDispatcher, concurrency = MAX_CONCURRENT_PROBES, ) { friend -> - val result = friend.connectAddress?.let { - probe.probe(it) + val directPresence = try { + directProbe(friend) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null } - val presence = result?.getOrNull() + val connectPresence = if (directPresence == null) { + friend.connectAddress?.let { address -> + if (connectAddressesMatch(address, ownAddress)) { + null + } else { + probe.probe(address).getOrNull() + } + } + } else { + null + } + val presence = directPresence ?: connectPresence friend.peerId to RemoteFriendPresence( peerId = friend.peerId, displayName = friend.displayName, @@ -71,6 +101,11 @@ class FriendPresenceMonitor private constructor( description = presence?.description, notifyWhenOnline = friend.permissions.notifyWhenOnline, + route = when { + directPresence != null -> ShareRoute.DIRECT_LAN + connectPresence != null -> ShareRoute.CONNECT + else -> null + }, ) } mutableState.value = results.toMap() @@ -80,7 +115,18 @@ class FriendPresenceMonitor private constructor( internal fun testing( friends: () -> List, probe: FriendStatusProbe, - ) = FriendPresenceMonitor(friends, probe) + directProbe: suspend (SavedFriend) -> ServerPresence? = { + null + }, + ownConnectAddress: () -> String? = { null }, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ) = FriendPresenceMonitor( + friends, + probe, + directProbe, + ownConnectAddress, + 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/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt index 65a0b2d32..f46c8cc93 100644 --- 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 @@ -29,6 +29,7 @@ class FriendRequestServer( private val friendStore: FriendStore, private val now: () -> Instant = Instant::now, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val onRelationshipChanged: () -> Unit = {}, ) : FriendControlServer { override fun handle( context: FriendControlContext, @@ -100,6 +101,7 @@ class FriendRequestServer( if (received.isLeft()) { FriendControlResponse.Invalid } else { + notifyRelationshipChanged() issueHostCard(instant) } } @@ -120,6 +122,14 @@ class FriendRequestServer( 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, ) { 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..9fae32759 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngress.kt @@ -0,0 +1,147 @@ +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 + } + } + } + + 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..875c01e00 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngress.kt @@ -0,0 +1,158 @@ +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.sync.Mutex +import kotlinx.coroutines.sync.withLock + +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 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 -> { + mutableState.value = PersistentDirectState.Starting + 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() + } + } + } + } + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle = startControl( + 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 = {}) + } + } +} 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 index 3f1d9281c..059b07ad9 100644 --- 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 @@ -2,17 +2,22 @@ package com.minekube.connect.share.fabric.ui import arrow.core.Either import arrow.core.left +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.direct.ShareRoute 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.tunnel.p2p.DirectP2pAuthMode import java.time.Instant -import java.util.Base64 +import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -32,9 +37,16 @@ data class OutgoingFriendRequestSummary( val displayName: String, ) +data class IncomingFriendRequestSummary( + val requestId: UUID, + val displayName: String, + val ingress: Ingress, +) + data class FriendsUiState( val friends: List = emptyList(), val outgoingRequests: List = emptyList(), + val incomingRequests: List = emptyList(), val safeMessage: String? = null, ) @@ -43,6 +55,8 @@ class FriendsViewModel( ) { private var discovered: List = emptyList() private var remotePresence: Map = emptyMap() + private var incomingRequests: List = + emptyList() private val mutableState = MutableStateFlow(loadInitialState()) val state: StateFlow = mutableState.asStateFlow() @@ -120,24 +134,52 @@ class FriendsViewModel( refresh(preserveSafeMessage = true) } + fun updateIncoming(pending: List) { + val next = pending + .asSequence() + .filter { it.purpose == AdmissionPurpose.FRIEND } + .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 + }, + ) + } + .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) + return browser.join(friend, authMode, ownConnectAddress) } suspend fun routeOutgoing( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, + ownConnectAddress: String? = null, ): Either { val request = outgoingRequest(peerId) ?: return GuestJoinFailure.NoRoute.left() - return browser.join(request, authMode) + return browser.join(request, authMode, ownConnectAddress) } fun reload() { @@ -192,6 +234,7 @@ class FriendsViewModel( displayName = it.displayName, ) }, + incomingRequests = incomingRequests, ) private fun update(transform: FriendsUiState.() -> FriendsUiState) { @@ -199,13 +242,6 @@ class FriendsViewModel( } private fun SavedFriend.summary(): FriendSummary { - val presence = discovered.firstOrNull { - val invitation = it.invitation - invitation.payload.peerId == peerId && - invitation.payload.shareId == shareId && - Base64.getEncoder().encodeToString(invitation.publicKey) == - publicKeyBase64 - } val remote = remotePresence[peerId] ?.takeIf { it.online } return FriendSummary( @@ -213,9 +249,9 @@ class FriendsViewModel( displayName = displayName, connectAvailable = connectAddress != null, permissions = permissions, - onlineViaLan = presence != null, - onlineViaConnect = remote != null, - worldName = presence?.displayName ?: remote?.description, + onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, + onlineViaConnect = remote?.route == ShareRoute.CONNECT, + worldName = remote?.description, ) } 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 index 1e7eb4d6c..d9a6cc44a 100644 --- 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 @@ -1,20 +1,44 @@ 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 = backgroundScope, + scope = this, stopShare = { stopCalls++ }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), ) runtime.integratedWorldChanged(worldAvailable = true) @@ -29,10 +53,12 @@ class ConnectShareRuntimeTest { fun `replacing an integrated world stops the previous share`() = runTest { var stopCalls = 0 val runtime = ConnectShareRuntime( - scope = backgroundScope, + scope = this, stopShare = { stopCalls++ }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), ) runtime.integratedWorldChanged(worldAvailable = true, identity = "one") @@ -46,13 +72,15 @@ class ConnectShareRuntimeTest { fun `enabled sharing resumes when the host enters or switches worlds`() = runTest { val lifecycle = mutableListOf() val runtime = ConnectShareRuntime( - scope = backgroundScope, + scope = this, stopShare = { lifecycle += "stop" }, resumeShare = { lifecycle += "resume" }, + lifecycleDispatcher = + StandardTestDispatcher(testScheduler), ) runtime.integratedWorldChanged(worldAvailable = true, identity = "one") 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..b23d97a9a --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/DirectControlPlaneTest.kt @@ -0,0 +1,134 @@ +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.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 `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 + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + starts++ + 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/FabricSessionAdmissionGateTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricSessionAdmissionGateTest.kt index a86286a05..a9a0f6fe9 100644 --- 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 @@ -23,6 +23,41 @@ 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() 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 index 3b128beb5..5f65efb48 100644 --- 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 @@ -147,7 +147,8 @@ class FabricShareBrowserTest { @Test fun `saved friend ignores LAN metadata signed by a different identity`() = runTest { val node = FakeGuestNode() - val browser = browser(node) + val reports = mutableListOf() + val browser = browser(node, reports::add) browser.start() val friend = savedFriend(invitation()) node.discover( @@ -166,9 +167,70 @@ class FabricShareBrowserTest { assertIs>(result) assertTrue(node.openedAddresses.isEmpty()) + assertEquals( + listOf( + "Connect Share route: direct LAN unavailable", + "Connect Share route: using Connect fallback", + ), + reports, + ) browser.close() } + @Test + fun `saved friend never falls back through this profiles own Connect endpoint`() = + runTest { + val node = FakeGuestNode() + 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(), + ) + assertTrue(node.openedAddresses.isEmpty()) + 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 `pasted invitation ignores discovery with a different peer`() = runTest { val node = FakeGuestNode() @@ -229,7 +291,8 @@ class FabricShareBrowserTest { fun `failed direct reachability falls back to Connect exactly once`() = runTest { val node = FakeGuestNode(failDirect = true) - val browser = browser(node) + val reports = mutableListOf() + val browser = browser(node, reports::add) val result = browser.join( invitationUri = invitation(), @@ -244,6 +307,14 @@ class FabricShareBrowserTest { 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() } @@ -265,11 +336,15 @@ class FabricShareBrowserTest { browser.close() } - private fun kotlinx.coroutines.test.TestScope.browser(node: FakeGuestNode) = + 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( 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..67a74cbad --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt @@ -0,0 +1,253 @@ +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.direct.DirectSessionRegistry +import com.minekube.connect.share.friend.FriendStore +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.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 hostServer = FriendRequestServer( + scope = this, + admission = admission, + issuer = FriendCardIssuer(hostDirectory) { + "host.play.minekube.net" + }, + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + + 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 pairing = FriendPairingClient( + store = senderStore, + issuer = FriendCardIssuer(senderDirectory) { + "sender.play.minekube.net" + }, + receiver = FriendCardReceiver(senderStore), + requestClient = FriendRequestClient( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + connectTimeout = Duration.ofSeconds(3), + decisionTimeout = Duration.ofSeconds(5), + ), + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + var received = false + 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 = true }, + ) + } + + val pending = withTimeout(5.seconds) { + admission.pending + .first { it.isNotEmpty() } + .single() + } + assertTrue(received) + admission.answer(pending.requestId, allow = true) + + assertTrue(result.await().isRight()) + assertEquals( + "bob", + hostStore.all().single().displayName, + ) + assertEquals( + "RoboFlax2", + senderStore.all().single().displayName, + ) + } 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) { + node.publish(invitation) + } + + 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..095d835dd --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingE2ETest.kt @@ -0,0 +1,138 @@ +package com.minekube.connect.share.fabric + +import arrow.core.right +import com.minekube.connect.share.ShareConnectionGateway +import com.minekube.connect.share.admission.AdmissionController +import com.minekube.connect.share.friend.FriendRelationshipStatus +import com.minekube.connect.share.friend.FriendStore +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.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +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 FriendPairingE2ETest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `signed request accepted through title gateway persists mutual friendship`() = + runBlocking { + val now = Instant.parse("2026-07-31T10:30:00Z") + val hostDirectory = tempDir.resolve("host") + val senderDirectory = tempDir.resolve("sender") + val hostStore = FriendStore(hostDirectory) + val senderStore = FriendStore(senderDirectory) + val hostAddress = AtomicReference() + var hostRelationshipsChanged = 0 + val hostIssuer = FriendCardIssuer(hostDirectory) { + hostAddress.get() + } + val admission = AdmissionController( + scope = this, + timeout = 10.seconds, + maxPending = 8, + connectedCount = { 0 }, + maxGuests = { 8 }, + ) + val hostServer = FriendRequestServer( + scope = this, + admission = admission, + issuer = hostIssuer, + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { now }, + ioDispatcher = Dispatchers.IO, + onRelationshipChanged = { + hostRelationshipsChanged++ + }, + ) + ShareConnectionGateway.bind(hostServer).use { gateway -> + hostAddress.set( + "${gateway.directAddress.hostString}:" + + gateway.directAddress.port, + ) + val invitation = hostIssuer.issue(now).getOrNull()!! + val pairing = FriendPairingClient( + store = senderStore, + issuer = FriendCardIssuer(senderDirectory) { + "sender.play.minekube.net" + }, + receiver = FriendCardReceiver(senderStore), + requestClient = FriendRequestClient( + protocolVersion = 1_075, + ioDispatcher = Dispatchers.IO, + connectTimeout = Duration.ofSeconds(2), + decisionTimeout = Duration.ofSeconds(5), + ), + now = { now }, + ioDispatcher = Dispatchers.IO, + ) + var received = false + + val result = async { + pairing.send( + invitation = invitation, + friendDisplayName = "RoboFlax2", + senderDisplayName = "bob", + route = { saved -> + GuestJoinTarget.Connect( + checkNotNull(saved.connectAddress), + ).right() + }, + onReceived = { received = true }, + ) + } + + val pending = withTimeout(2.seconds) { + admission.pending.first { it.isNotEmpty() }.single() + } + assertTrue(received) + assertTrue(hostStore.all().isEmpty()) + assertEquals( + FriendRelationshipStatus.PENDING_OUTGOING, + senderStore.outgoingRequests() + .single() + .relationshipStatus, + ) + + admission.answer(pending.requestId, allow = true) + val accepted = result.await().getOrNull()!! + + assertEquals( + FriendRelationshipStatus.CONFIRMED, + accepted.relationshipStatus, + ) + assertTrue(senderStore.outgoingRequests().isEmpty()) + assertEquals("RoboFlax2", senderStore.all().single().displayName) + assertEquals("bob", hostStore.all().single().displayName) + assertEquals(1, hostRelationshipsChanged) + assertTrue( + senderStore.all() + .single() + .permissions + .canJoinAutomatically, + ) + assertTrue( + hostStore.all() + .single() + .permissions + .canJoinAutomatically, + ) + assertFalse( + senderStore.all().single().peerId == + hostStore.all().single().peerId, + ) + } + } +} 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 index 64fe60cdd..413773ff8 100644 --- 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 @@ -1,16 +1,49 @@ package com.minekube.connect.share.fabric import arrow.core.Either +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() + }, + probe = FriendStatusProbe { + error("no friends should be probed") + }, + 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( @@ -44,6 +77,57 @@ class FriendPresenceMonitorTest { assertFalse(presence.toString().contains("capability-secret")) } + @Test + fun `direct LAN status is preferred before Connect presence`() = runTest { + val nearby = friend( + peerId = "12D3KooWNearby", + address = "nearby.play.minekube.net", + ) + val connectProbes = mutableListOf() + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(nearby) }, + directProbe = { + ServerPresence("Robin's LAN World") + }, + probe = FriendStatusProbe { address -> + connectProbes += address + Either.Right(ServerPresence("Wrong Connect 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) + assertTrue(connectProbes.isEmpty()) + } + + @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") + }, + probe = FriendStatusProbe { + Either.Right(ServerPresence("must not run")) + }, + ) + + assertFailsWith { + monitor.refresh() + } + } + @Test fun `online notification fires once per transition and respects preference`() { val tracker = FriendOnlineTracker() @@ -77,6 +161,29 @@ class FriendPresenceMonitorTest { ) } + @Test + fun `refresh never probes this profiles own Connect endpoint as a friend`() = + runTest { + val copied = friend( + peerId = "12D3KooWCopiedEndpoint", + address = "mine.play.minekube.net", + ) + val probed = mutableListOf() + val monitor = FriendPresenceMonitor.testing( + friends = { listOf(copied) }, + ownConnectAddress = { "mine.play.minekube.net" }, + probe = FriendStatusProbe { address -> + probed += address + Either.Right(ServerPresence("Wrong self presence")) + }, + ) + + monitor.refresh() + + assertTrue(probed.isEmpty()) + assertFalse(monitor.state.value.getValue(copied.peerId).online) + } + private fun friend( peerId: String, address: String, 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..f445610f0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentConnectIngressTest.kt @@ -0,0 +1,130 @@ +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() + } + + 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..333f4bff8 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PersistentDirectIngressTest.kt @@ -0,0 +1,158 @@ +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 `title startup and world leases share one direct host until shutdown`() = + 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, + TARGET, + CONNECT_ADDRESS, + ) + firstWorld.close() + secondWorld.close() + + assertEquals(0, delegate.closes.get()) + assertEquals(INVITATION, firstWorld.invitation) + assertTrue(firstWorld.lanAvailable) + + persistent.shutdown() + persistent.shutdown() + + assertEquals(1, 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.start( + CONTROL_OPTIONS, + InetSocketAddress(InetAddress.getLoopbackAddress(), 25_566), + CONNECT_ADDRESS, + ) + } + assertFailsWith { + persistent.start( + CONTROL_OPTIONS, + TARGET, + "other.play.minekube.net", + ) + } + + persistent.shutdown() + } + + private class FakeIngress( + private val failuresBeforeSuccess: Int = 0, + ) : DirectShareIngress { + val starts = AtomicInteger() + val closes = AtomicInteger() + + override suspend fun start( + options: ShareOptions, + target: SocketAddress, + connectAddress: String?, + ): DirectShareHandle { + val attempt = starts.incrementAndGet() + if (attempt <= failuresBeforeSuccess) { + error("simulated direct startup failure") + } + return DirectShareHandle( + invitation = INVITATION, + 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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt index f65b6c38b..af8e4a7f1 100644 --- 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 @@ -3,11 +3,16 @@ 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.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.tunnel.p2p.DirectP2pAuthMode @@ -75,6 +80,58 @@ class FriendsViewModelTest { ) } + @Test + fun `title friends state exposes only incoming friend 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.single() + assertEquals(friendRequestId, incoming.requestId) + assertEquals("bob", incoming.displayName) + assertEquals(Ingress.CONNECT, incoming.ingress) + 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)) @@ -155,16 +212,42 @@ class FriendsViewModelTest { ), ), ) + 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, + ), + ), + ) 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) @@ -179,6 +262,7 @@ class FriendsViewModelTest { online = true, description = "Robin's Remote World", notifyWhenOnline = true, + route = ShareRoute.CONNECT, ), ), ) From df4f6135a1cc90e9503226e68fb9b7808f528488 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 12:27:52 +0200 Subject: [PATCH 129/188] fix(share): pair friends over libp2p only --- .../connect/share/direct/ShareInviteCodec.kt | 75 +++++++-- .../friend/FriendControlChannelHandler.kt | 2 +- .../connect/share/friend/FriendControlWire.kt | 48 +----- .../connect/share/friend/FriendStore.kt | 30 +++- .../share/GatewayMinecraftBridgeTest.kt | 40 ++--- .../share/ShareConnectionGatewayTest.kt | 33 ++-- .../share/direct/ShareInviteCodecTest.kt | 36 +++++ .../friend/FriendControlChannelHandlerTest.kt | 32 ++-- .../share/friend/FriendControlWireTest.kt | 10 +- .../v1_21_11/ConnectShare12111Client.kt | 4 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 7 +- .../v1_21_11/Fabric12111ArtifactTest.kt | 1 + .../fabric/v26_2/ConnectShare262Client.kt | 4 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 7 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 1 + .../share/fabric/FabricShareBootstrap.kt | 14 +- .../share/fabric/FabricShareBrowser.kt | 17 ++ .../connect/share/fabric/FriendCardIssuer.kt | 30 ++-- .../share/fabric/FriendPairingClient.kt | 5 + .../share/fabric/FriendRequestClient.kt | 59 +------ .../share/fabric/FriendRequestServer.kt | 31 +++- .../share/fabric/ui/FriendsViewModel.kt | 16 +- .../share/fabric/FriendCardIssuerTest.kt | 2 + .../fabric/FriendPairingDirectE2ETest.kt | 1 - .../share/fabric/FriendPairingE2ETest.kt | 149 +++++------------- .../share/fabric/FriendRequestClientTest.kt | 27 ++-- .../share/fabric/FriendRequestServerTest.kt | 51 +++++- .../share/fabric/ui/FriendsViewModelTest.kt | 39 ++++- 28 files changed, 416 insertions(+), 355 deletions(-) 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 index 539ae798d..f40f4825c 100644 --- 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 @@ -20,6 +20,7 @@ class ShareInvitePayload( val internetDirectEnabled: Boolean, val directCandidates: List, val capability: String, + val displayName: String? = null, ) { override fun equals(other: Any?): Boolean = other is ShareInvitePayload && @@ -30,7 +31,8 @@ class ShareInvitePayload( peerId == other.peerId && internetDirectEnabled == other.internetDirectEnabled && directCandidates == other.directCandidates && - capability == other.capability + capability == other.capability && + displayName == other.displayName override fun hashCode(): Int { var result = wireVersion @@ -41,6 +43,7 @@ class ShareInvitePayload( result = 31 * result + internetDirectEnabled.hashCode() result = 31 * result + directCandidates.hashCode() result = 31 * result + capability.hashCode() + result = 31 * result + (displayName?.hashCode() ?: 0) return result } @@ -49,7 +52,8 @@ class ShareInvitePayload( "expiresAtEpochMillis=$expiresAtEpochMillis, " + "connectAddress=$connectAddress, peerId=$peerId, " + "internetDirectEnabled=$internetDirectEnabled, " + - "directCandidates=, capability=)" + "directCandidates=, capability=, " + + "displayName=$displayName)" } class SignedShareInvite( @@ -107,16 +111,26 @@ sealed interface ShareInviteError { } object ShareInviteCodec { - const val WIRE_VERSION = 1 + 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 FIELD_COUNT = 10 - private const val UNSIGNED_FIELD_COUNT = 9 + 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 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(FIELD_COUNT) + writer.array(fieldCount(invite.payload.wireVersion)) writer.invitePayload(invite.payload) writer.bytes(invite.publicKey) writer.bytes(invite.signature) @@ -129,7 +143,13 @@ object ShareInviteCodec { payload: ShareInvitePayload, publicKey: ByteArray, ): ByteArray = CborWriter().apply { - array(UNSIGNED_FIELD_COUNT) + 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() @@ -149,7 +169,10 @@ object ShareInviteCodec { } return either { ensure(verify(parsed)) { ShareInviteError.InvalidSignature } - ensure(parsed.payload.wireVersion == WIRE_VERSION) { + ensure( + parsed.payload.wireVersion == LEGACY_WIRE_VERSION || + parsed.payload.wireVersion == WIRE_VERSION, + ) { ShareInviteError.UnsupportedVersion(parsed.payload.wireVersion) } ensure(parsed.payload.expiresAtEpochMillis >= now.toEpochMilli()) { @@ -165,6 +188,14 @@ object ShareInviteCodec { ) { ShareInviteError.PeerMismatch } + ensure( + parsed.payload.displayName?.let { displayName -> + displayName == displayName.trim() && + displayName.length in 1..MAX_DISPLAY_NAME_LENGTH + } != false, + ) { + ShareInviteError.Malformed + } parsed } } @@ -203,8 +234,25 @@ object ShareInviteCodec { 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() @@ -279,9 +327,11 @@ object ShareInviteCodec { private var offset = 0 fun readInvite(): SignedShareInvite { - require(readLength(4) == FIELD_COUNT) + val fields = readLength(4) + val wireVersion = unsigned().toInt() + require(fields == fieldCount(wireVersion)) val payload = ShareInvitePayload( - wireVersion = unsigned().toInt(), + wireVersion = wireVersion, shareId = UUID.fromString(text()), expiresAtEpochMillis = unsigned(), connectAddress = nullableText(), @@ -289,6 +339,11 @@ object ShareInviteCodec { internetDirectEnabled = bool(), directCandidates = List(readLength(4)) { text() }, capability = text(), + displayName = if (wireVersion == LEGACY_WIRE_VERSION) { + null + } else { + nullableText() + }, ) val publicKey = byteString() val signature = byteString() 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 index bc4319b45..8c5b3991d 100644 --- 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 @@ -58,7 +58,7 @@ class FriendControlChannelHandler( if (!controlHandshake) { when ( val inspected = - FriendControlWire.inspectControlHandshake(accumulated) + FriendControlWire.inspectControlRequest(accumulated) ) { FriendControlDecode.Incomplete -> return FriendControlDecode.Invalid -> { 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 index 0208caa61..bc8f510a0 100644 --- 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 @@ -38,11 +38,9 @@ sealed interface FriendControlDecode { object FriendControlWire { const val MAX_REQUEST_BYTES = 65_536 - const val CONTROL_HANDSHAKE_PORT = 24_454 private const val STATUS_INTENTION = 1 private const val HANDSHAKE_PACKET_ID = 0 - private const val STATUS_REQUEST_PACKET_ID = 0 private const val CONTROL_REQUEST_PACKET_ID = 0x43F1 private const val CONTROL_RESPONSE_PACKET_ID = 0x43F2 private const val MAX_ADDRESS_BYTES = 255 @@ -50,16 +48,8 @@ object FriendControlWire { private const val MAX_INVITATION_BYTES = 32_768 fun encodeRequest( - protocolVersion: Int, - serverAddress: String, request: FriendControlRequest, ): ByteArray { - require(protocolVersion >= 0) { - "Minecraft protocol version must not be negative" - } - require(serverAddress.toByteArray(StandardCharsets.UTF_8).size <= MAX_ADDRESS_BYTES) { - "Minecraft server address is too long" - } require( request.displayName.trim().isNotEmpty() && request.displayName.toByteArray(StandardCharsets.UTF_8).size <= @@ -75,17 +65,6 @@ object FriendControlWire { } val output = ByteArrayOutputStream() - output.writePacket { - writeVarInt(HANDSHAKE_PACKET_ID) - writeVarInt(protocolVersion) - writeString(serverAddress) - write((CONTROL_HANDSHAKE_PORT ushr 8) and 0xff) - write(CONTROL_HANDSHAKE_PORT and 0xff) - writeVarInt(STATUS_INTENTION) - } - output.writePacket { - writeVarInt(STATUS_REQUEST_PACKET_ID) - } output.writePacket { writeVarInt(CONTROL_REQUEST_PACKET_ID) writeLong(request.requestId.mostSignificantBits) @@ -107,20 +86,6 @@ object FriendControlWire { return FriendControlDecode.Invalid } return decode(bytes) { - val handshake = readPacket() - ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) - handshake.readVarInt() - handshake.readString(MAX_ADDRESS_BYTES) - ensure(handshake.readUnsignedShort() == CONTROL_HANDSHAKE_PORT) - ensure(handshake.readVarInt() == STATUS_INTENTION) - handshake.ensureFinished() - - val statusRequest = readPacket() - ensure( - statusRequest.readVarInt() == STATUS_REQUEST_PACKET_ID, - ) - statusRequest.ensureFinished() - val control = readPacket() ensure(control.readVarInt() == CONTROL_REQUEST_PACKET_ID) val requestId = UUID( @@ -156,18 +121,11 @@ object FriendControlWire { false } - fun inspectControlHandshake( + fun inspectControlRequest( bytes: ByteArray, ): FriendControlDecode = decode(bytes) { - val handshake = readPacket() - ensure(handshake.readVarInt() == HANDSHAKE_PACKET_ID) - handshake.readVarInt() - handshake.readString(MAX_ADDRESS_BYTES) - val port = handshake.readUnsignedShort() - val intention = handshake.readVarInt() - handshake.ensureFinished() - port == CONTROL_HANDSHAKE_PORT && - intention == STATUS_INTENTION + val firstPacket = readPacket() + firstPacket.readVarInt() == CONTROL_REQUEST_PACKET_ID } fun encodeResponse(response: FriendControlResponse): ByteArray { 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 index 382655770..fe3d14b6e 100644 --- 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 @@ -1,8 +1,10 @@ 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 @@ -98,6 +100,10 @@ class FriendStore( FriendRelationshipStatus.PENDING_OUTGOING } + @Synchronized + fun relationship(peerId: String): Option = + read().firstOrNull { it.peerId == peerId }.toOption() + @Synchronized fun accept( invitationUri: String, @@ -111,6 +117,20 @@ class FriendStore( now = now, ) + @Synchronized + fun acceptAndAllowJoin( + invitationUri: String, + displayName: String, + now: Instant = Instant.now(), + ): Either = + storeInvitation( + invitationUri = invitationUri, + displayName = displayName, + relationshipStatus = FriendRelationshipStatus.CONFIRMED, + allowAutomaticJoin = true, + now = now, + ) + @Synchronized fun sendRequest( invitationUri: String, @@ -138,6 +158,7 @@ class FriendStore( invitationUri: String, displayName: String, relationshipStatus: FriendRelationshipStatus, + allowAutomaticJoin: Boolean = false, now: Instant, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) @@ -171,7 +192,14 @@ class FriendStore( connectAddress = invite.payload.connectAddress, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, - permissions = existing?.permissions ?: FriendPermissions(), + permissions = (existing?.permissions ?: FriendPermissions()) + .let { permissions -> + if (allowAutomaticJoin) { + permissions.copy(canJoinAutomatically = true) + } else { + permissions + } + }, relationshipStatus = effectiveRelationshipStatus, ) write( 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 index 71593ff55..2ece42efb 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt @@ -149,34 +149,16 @@ class GatewayMinecraftBridgeTest { } private companion object { - val CONTROL_REQUEST = com.minekube.connect.share.friend - .FriendControlRequest( - requestId = java.util.UUID.fromString( - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - ), - displayName = "ordinary", - invitation = "minekube://share/ordinary", - ) - val MINECRAFT_BYTES = - com.minekube.connect.share.friend.FriendControlWire - .encodeRequest( - protocolVersion = 1_075, - serverAddress = "ordinary-minecraft", - request = CONTROL_REQUEST, - ).copyOf().also { bytes -> - val port = - com.minekube.connect.share.friend - .FriendControlWire - .CONTROL_HANDSHAKE_PORT - val high = port ushr 8 - val low = port and 0xff - val index = bytes.indices.first { - it + 1 < bytes.size && - bytes[it].toInt() and 0xff == high && - bytes[it + 1].toInt() and 0xff == low - } - bytes[index] = (25_565 ushr 8).toByte() - bytes[index + 1] = 25_565.toByte() - } + 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 index 11e144686..20cdab1c9 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -41,8 +41,6 @@ class ShareConnectionGatewayTest { socket.getOutputStream().apply { write( FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "connect-share", request = REQUEST, ), ) @@ -199,8 +197,6 @@ class ShareConnectionGatewayTest { channel.writeAndFlush( Unpooled.wrappedBuffer( FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "friend-control", request = REQUEST, ), ), @@ -293,23 +289,16 @@ class ShareConnectionGatewayTest { invitation = "minekube://share/sender-card", ) const val HOST_CARD = "minekube://share/host-card" - val ORDINARY_MINECRAFT_BYTES = - FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "ordinary-minecraft", - request = REQUEST, - ).copyOf().also { bytes -> - val controlHigh = - FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 - val controlLow = - FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff - val portIndex = bytes.indices.first { - it + 1 < bytes.size && - bytes[it].toInt() and 0xff == controlHigh && - bytes[it + 1].toInt() and 0xff == controlLow - } - bytes[portIndex] = (25_565 ushr 8).toByte() - bytes[portIndex + 1] = 25_565.toByte() - } + 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/direct/ShareInviteCodecTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/direct/ShareInviteCodecTest.kt index 64829b1a9..66b97f635 100644 --- 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 @@ -47,6 +47,40 @@ class ShareInviteCodecTest { 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() @@ -105,6 +139,7 @@ class ShareInviteCodecTest { 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", ), @@ -117,6 +152,7 @@ class ShareInviteCodecTest { internetDirectEnabled = true, directCandidates = directCandidates, capability = CAPABILITY, + displayName = displayName, ) private fun ShareInvitePayload.signWith(keyPair: KeyPair): SignedShareInvite { 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 index 551f0179d..94c54f17c 100644 --- 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 @@ -19,22 +19,7 @@ import kotlin.test.assertTrue class FriendControlChannelHandlerTest { @Test fun `ordinary Minecraft traffic passes through unchanged`() { - val ordinary = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "localhost", - request = REQUEST, - ).copyOf() - val controlHigh = - FriendControlWire.CONTROL_HANDSHAKE_PORT ushr 8 - val controlLow = - FriendControlWire.CONTROL_HANDSHAKE_PORT and 0xff - val portIndex = ordinary.indices.first { - it + 1 < ordinary.size && - ordinary[it].toInt() and 0xff == controlHigh && - ordinary[it + 1].toInt() and 0xff == controlLow - } - ordinary[portIndex] = (25_565 ushr 8).toByte() - ordinary[portIndex + 1] = 25_565.toByte() + val ordinary = ORDINARY_MINECRAFT_HANDSHAKE val channel = EmbeddedChannel( FriendControlChannelHandler { _, _ -> error("Ordinary traffic must not reach friend control") @@ -67,8 +52,6 @@ class FriendControlChannelHandlerTest { ) channel.attr(DirectSessionAttributes.SESSION).set(DIRECT_SESSION) val encoded = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "connect-share", request = REQUEST, ) @@ -118,8 +101,6 @@ class FriendControlChannelHandlerTest { channel.writeInbound( Unpooled.wrappedBuffer( FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "purple-del.play.minekube.net", request = REQUEST, ), ), @@ -156,5 +137,16 @@ class FriendControlChannelHandlerTest { 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 index 64b7dae81..41d70a8e0 100644 --- 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 @@ -3,12 +3,12 @@ package com.minekube.connect.share.friend 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 uses a status handshake and round trips without a login`() { + fun `request is a raw control frame instead of a Minecraft status ping`() { val request = FriendControlRequest( requestId = REQUEST_ID, displayName = "bob", @@ -16,8 +16,6 @@ class FriendControlWireTest { ) val encoded = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "purple-del.play.minekube.net", request = request, ) val decoded = assertIs>( @@ -26,7 +24,7 @@ class FriendControlWireTest { assertEquals(request, decoded.value) assertEquals(encoded.size, decoded.consumedBytes) - assertTrue(FriendControlWire.isStatusHandshake(encoded)) + assertFalse(FriendControlWire.isStatusHandshake(encoded)) } @Test @@ -54,8 +52,6 @@ class FriendControlWireTest { @Test fun `partial and oversized control frames are never accepted`() { val encoded = FriendControlWire.encodeRequest( - protocolVersion = 1_075, - serverAddress = "purple-del.play.minekube.net", request = FriendControlRequest( requestId = REQUEST_ID, displayName = "bob", 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 index ca606795b..380eda167 100644 --- 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 @@ -60,8 +60,6 @@ class ConnectShare12111Client : ClientModInitializer { ) val minecraftVersion = SharedConstants.getCurrentVersion().name() - val minecraftProtocolVersion = - SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) @@ -93,11 +91,11 @@ class ConnectShare12111Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = minecraftVersion, - minecraftProtocolVersion = minecraftProtocolVersion, worldAvailable = worldAvailableSnapshot.get(), friendStore = friendStore, playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, bridgeFactory = { admission, admissionScope, 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 index ed76f443d..ff8b50e91 100644 --- 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 @@ -341,6 +341,11 @@ class ShareJoinScreen( setValue(invitationValue) setResponder { invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } refresh() } }, @@ -675,8 +680,6 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), - ownConnectAddress = - ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { 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 index 84535e097..fa1eb77d7 100644 --- 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 @@ -89,6 +89,7 @@ class Fabric12111ArtifactTest { "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) 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 index d4a6fada3..0340b73f2 100644 --- 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 @@ -60,8 +60,6 @@ class ConnectShare262Client : ClientModInitializer { ) val minecraftVersion = SharedConstants.getCurrentVersion().name() - val minecraftProtocolVersion = - SharedConstants.getProtocolVersion() val dataDirectory = FabricLoader.getInstance().configDir .resolve("minekube-connect-share") val friendStore = FriendStore(dataDirectory) @@ -93,11 +91,11 @@ class ConnectShare262Client : ClientModInitializer { scope = scope, dataDirectory = dataDirectory, minecraftVersion = minecraftVersion, - minecraftProtocolVersion = minecraftProtocolVersion, worldAvailable = worldAvailableSnapshot.get(), friendStore = friendStore, playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, + playerDisplayName = { client.user.name }, bridgeFactory = { admission, admissionScope, 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 index 33c5d796a..712194978 100644 --- 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 @@ -341,6 +341,11 @@ class ShareJoinScreen( setValue(invitationValue) setResponder { invitationValue = it + friends.suggestedDisplayName(it).getOrNull() + ?.let { suggested -> + nameValue = suggested + nameBox?.setValue(suggested) + } refresh() } }, @@ -675,8 +680,6 @@ class ShareJoinScreen( peerId = peerId, browser = browser, authMode = authMode(), - ownConnectAddress = - ConnectShareClient.connectPublicAddress(), ) val target = targetResult.getOrNull() if (target == null) { 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 index 6fe5b64ac..9660c135a 100644 --- 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 @@ -91,6 +91,7 @@ class Fabric262ArtifactTest { "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) 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 index f41a07608..7f19c7bcf 100644 --- 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 @@ -32,11 +32,11 @@ object FabricShareBootstrap { scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, - minecraftProtocolVersion: Int, worldAvailable: Boolean, friendStore: FriendStore, playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, + playerDisplayName: () -> String? = { null }, bridgeFactory: ( AdmissionController, @@ -97,9 +97,11 @@ object FabricShareBootstrap { val endpointIdentity = identityStore.currentOrCreate() val ownConnectAddress = "${endpointIdentity.endpoint}.play.minekube.net" - val friendCardIssuer = FriendCardIssuer(dataDirectory) { - ownConnectAddress - } + val friendCardIssuer = FriendCardIssuer( + dataDirectory = dataDirectory, + displayName = playerDisplayName, + connectAddress = { ownConnectAddress }, + ) val friendCardReceiver = FriendCardReceiver(friendStore) val friendsViewModel = FriendsViewModel(friendStore) val friendRequestServer = FriendRequestServer( @@ -181,9 +183,7 @@ object FabricShareBootstrap { resumeShare = viewModel::resumeIfEnabled, worldAvailabilityChanged = viewModel::setWorldAvailable, ) - val friendRequestClient = FriendRequestClient( - minecraftProtocolVersion, - ) + val friendRequestClient = FriendRequestClient() val friendPairingClient = FriendPairingClient( store = friendStore, issuer = friendCardIssuer, 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 index 3ec45f453..3cfe68594 100644 --- 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 @@ -257,6 +257,23 @@ class FabricShareBrowser private constructor( GuestJoinFailure.NoRoute.left() } + suspend fun openFriendControl( + friend: SavedFriend, + authMode: DirectP2pAuthMode, + ): Either = + withContext(ioDispatcher) { + val discovered = matchingLanShare(friend) + ?: return@withContext GuestJoinFailure.NoRoute.left() + openDirect( + route = ShareRoute.DIRECT_LAN, + address = discovered.lanAddress, + shareId = friend.shareId.toString(), + capability = friend.capability, + authMode = authMode, + timeout = LAN_TIMEOUT, + )?.right() ?: GuestJoinFailure.NoRoute.left() + } + suspend fun probeLan( friend: SavedFriend, authMode: DirectP2pAuthMode, 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 index 1316d647e..220414f43 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -30,14 +32,11 @@ class FriendCardReceiver( authenticatedMinecraftUuid: UUID?, now: Instant = Instant.now(), ): Either = - store.accept(invitation, displayName, now).flatMap { friend -> - store.updatePermissions( - friend.peerId, - friend.permissions.copy( - canJoinAutomatically = true, - ), - ) - }.flatMap { friend -> + store.acceptAndAllowJoin( + invitation, + displayName, + now, + ).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> store.linkMinecraftIdentity( friend.peerId, @@ -49,11 +48,19 @@ class FriendCardReceiver( class FriendCardIssuer( private val dataDirectory: Path, + private val displayName: () -> String? = { null }, private val connectAddress: suspend () -> String?, ) { suspend fun issue( now: Instant = Instant.now(), - ): Either = + ): Either = either { + val normalizedDisplayName = displayName()?.trim() + ensure( + normalizedDisplayName == null || + normalizedDisplayName.length in 1..MAX_DISPLAY_NAME_LENGTH, + ) { + FriendCardIssueFailure + } Either.catch { val access = ShareAccessIdentityStore( dataDirectory, @@ -72,6 +79,7 @@ class FriendCardIssuer( internetDirectEnabled = false, directCandidates = emptyList(), capability = access.capability, + displayName = normalizedDisplayName, ) val publicKey = node.publicKey() val unsigned = ShareInviteCodec.unsignedBytes( @@ -88,11 +96,13 @@ class FriendCardIssuer( } }.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/FriendPairingClient.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendPairingClient.kt index 236f1fca1..9a54ef96e 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -68,6 +69,10 @@ class FriendPairingClient( 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( 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 index d0135036d..edc1c68a4 100644 --- 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 @@ -9,7 +9,6 @@ import com.minekube.connect.share.friend.FriendControlResponse import com.minekube.connect.share.friend.FriendControlWire import java.io.ByteArrayOutputStream import java.io.InputStream -import java.net.InetSocketAddress import java.net.Socket import java.net.SocketTimeoutException import java.time.Duration @@ -46,32 +45,28 @@ sealed interface FriendRequestFailure { } class FriendRequestClient( - private val protocolVersion: Int, 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, + target: GuestJoinTarget.Direct, request: FriendControlRequest, onReceived: () -> Unit, ): Either = withContext(ioDispatcher) { target.use { - val route = target.routeTarget() val socket = Socket() val cancellation = coroutineContext[Job] ?.invokeOnCompletion { socket.close() } try { socket.connect( - route.socketAddress, + target.localAddress, connectTimeout.toMillis().toInt(), ) socket.soTimeout = READ_POLL_MILLIS socket.getOutputStream().apply { write( FriendControlWire.encodeRequest( - protocolVersion = protocolVersion, - serverAddress = route.handshakeAddress, request = request, ), ) @@ -176,57 +171,7 @@ class FriendRequestClient( } } - private fun GuestJoinTarget.routeTarget(): RouteTarget = when (this) { - is GuestJoinTarget.Connect -> { - val parsed = parseAddress(publicAddress) - RouteTarget( - socketAddress = parsed, - handshakeAddress = parsed.hostString, - ) - } - - is GuestJoinTarget.Direct -> RouteTarget( - socketAddress = localAddress, - handshakeAddress = "connect-share", - ) - } - - private fun parseAddress(value: String): InetSocketAddress { - val trimmed = value.trim() - if (trimmed.startsWith("[")) { - val closing = trimmed.indexOf(']') - require(closing > 1) { "Friend address is invalid" } - val host = trimmed.substring(1, closing) - val port = trimmed.substring(closing + 1) - .removePrefix(":") - .takeIf(String::isNotEmpty) - ?.toInt() - ?: DEFAULT_MINECRAFT_PORT - return InetSocketAddress(host, port) - } - val colon = trimmed.lastIndexOf(':') - val hasSingleColon = - colon > 0 && trimmed.indexOf(':') == colon - val host = if (hasSingleColon) { - trimmed.substring(0, colon) - } else { - trimmed - } - val port = if (hasSingleColon) { - trimmed.substring(colon + 1).toInt() - } else { - DEFAULT_MINECRAFT_PORT - } - return InetSocketAddress(host, port) - } - - private data class RouteTarget( - val socketAddress: InetSocketAddress, - val handshakeAddress: String, - ) - private companion object { - const val DEFAULT_MINECRAFT_PORT = 25_565 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 index f46c8cc93..d5c8f465c 100644 --- 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 @@ -4,11 +4,13 @@ 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.FriendControlServer +import com.minekube.connect.share.friend.FriendRelationshipStatus import com.minekube.connect.share.friend.FriendStore import java.time.Instant import java.util.Base64 @@ -54,27 +56,42 @@ class FriendRequestServer( 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 ( - context.directPeerId != null && - context.directPeerId != senderPeerId - ) { + if (authenticatedPeerId != senderPeerId) { return FriendControlResponse.Invalid } val senderKey = Base64.getEncoder() .encodeToString(invitation.publicKey) - val existing = friendStore.all().firstOrNull { - it.peerId == senderPeerId - } + val existing = friendStore.relationship(senderPeerId).getOrNull() if (existing != null) { if (existing.publicKeyBase64 != senderKey) { return FriendControlResponse.Invalid } + if ( + existing.relationshipStatus == + FriendRelationshipStatus.PENDING_OUTGOING + ) { + val accepted = receiver.receive( + invitation = request.invitation, + displayName = request.displayName, + authenticatedMinecraftUuid = null, + now = instant, + ) + if (accepted.isLeft()) { + return FriendControlResponse.Invalid + } + notifyRelationshipChanged() + } return issueHostCard(instant) } 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 index 059b07ad9..34afb59a6 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -12,6 +14,7 @@ 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.direct.ShareInviteCodec import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.SavedFriend @@ -77,6 +80,14 @@ class FriendsViewModel( }, ) + 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 -> @@ -175,11 +186,10 @@ class FriendsViewModel( peerId: String, browser: FabricShareBrowser, authMode: DirectP2pAuthMode, - ownConnectAddress: String? = null, - ): Either { + ): Either { val request = outgoingRequest(peerId) ?: return GuestJoinFailure.NoRoute.left() - return browser.join(request, authMode, ownConnectAddress) + return browser.openFriendControl(request, authMode) } fun reload() { 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 index a7e777ee3..668f7c1ff 100644 --- 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 @@ -23,6 +23,7 @@ class FriendCardIssuerTest { val issuer = FriendCardIssuer( dataDirectory = tempDir, connectAddress = { "purple-del.play.minekube.net" }, + displayName = { "RoboFlax2" }, ) val first = assertIs>( @@ -54,6 +55,7 @@ class FriendCardIssuerTest { "purple-del.play.minekube.net", firstInvite.payload.connectAddress, ) + assertEquals("RoboFlax2", firstInvite.payload.displayName) assertTrue(firstInvite.payload.directCandidates.isEmpty()) } 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 index 67a74cbad..40f455d6f 100644 --- 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 @@ -109,7 +109,6 @@ class FriendPairingDirectE2ETest { }, receiver = FriendCardReceiver(senderStore), requestClient = FriendRequestClient( - protocolVersion = 1_075, ioDispatcher = Dispatchers.IO, connectTimeout = Duration.ofSeconds(3), decisionTimeout = Duration.ofSeconds(5), 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 index 095d835dd..5dc521b4e 100644 --- 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 @@ -1,24 +1,18 @@ package com.minekube.connect.share.fabric +import arrow.core.Either import arrow.core.right -import com.minekube.connect.share.ShareConnectionGateway -import com.minekube.connect.share.admission.AdmissionController import com.minekube.connect.share.friend.FriendRelationshipStatus import com.minekube.connect.share.friend.FriendStore 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.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds 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 FriendPairingE2ETest { @@ -26,113 +20,52 @@ class FriendPairingE2ETest { lateinit var tempDir: Path @Test - fun `signed request accepted through title gateway persists mutual friendship`() = + fun `Connect Minecraft route is refused before friend delivery`() = runBlocking { val now = Instant.parse("2026-07-31T10:30:00Z") - val hostDirectory = tempDir.resolve("host") - val senderDirectory = tempDir.resolve("sender") - val hostStore = FriendStore(hostDirectory) - val senderStore = FriendStore(senderDirectory) - val hostAddress = AtomicReference() - var hostRelationshipsChanged = 0 - val hostIssuer = FriendCardIssuer(hostDirectory) { - hostAddress.get() - } - val admission = AdmissionController( - scope = this, - timeout = 10.seconds, - maxPending = 8, - connectedCount = { 0 }, - maxGuests = { 8 }, + val hostIssuer = FriendCardIssuer( + dataDirectory = tempDir.resolve("host"), + connectAddress = { "host.play.minekube.net" }, ) - val hostServer = FriendRequestServer( - scope = this, - admission = admission, - issuer = hostIssuer, - receiver = FriendCardReceiver(hostStore), - friendStore = hostStore, + 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, - onRelationshipChanged = { - hostRelationshipsChanged++ - }, ) - ShareConnectionGateway.bind(hostServer).use { gateway -> - hostAddress.set( - "${gateway.directAddress.hostString}:" + - gateway.directAddress.port, - ) - val invitation = hostIssuer.issue(now).getOrNull()!! - val pairing = FriendPairingClient( - store = senderStore, - issuer = FriendCardIssuer(senderDirectory) { - "sender.play.minekube.net" - }, - receiver = FriendCardReceiver(senderStore), - requestClient = FriendRequestClient( - protocolVersion = 1_075, - ioDispatcher = Dispatchers.IO, - connectTimeout = Duration.ofSeconds(2), - decisionTimeout = Duration.ofSeconds(5), - ), - now = { now }, - ioDispatcher = Dispatchers.IO, - ) - var received = false - - val result = async { - pairing.send( - invitation = invitation, - friendDisplayName = "RoboFlax2", - senderDisplayName = "bob", - route = { saved -> - GuestJoinTarget.Connect( - checkNotNull(saved.connectAddress), - ).right() - }, - onReceived = { received = true }, - ) - } + var received = false - val pending = withTimeout(2.seconds) { - admission.pending.first { it.isNotEmpty() }.single() - } - assertTrue(received) - assertTrue(hostStore.all().isEmpty()) - assertEquals( - FriendRelationshipStatus.PENDING_OUTGOING, - senderStore.outgoingRequests() - .single() - .relationshipStatus, - ) - - admission.answer(pending.requestId, allow = true) - val accepted = result.await().getOrNull()!! + val result = pairing.send( + invitation = invitation, + friendDisplayName = "RoboFlax2", + senderDisplayName = "bob", + route = { + GuestJoinTarget.Connect( + "host.play.minekube.net", + ).right() + }, + onReceived = { received = true }, + ) - assertEquals( - FriendRelationshipStatus.CONFIRMED, - accepted.relationshipStatus, - ) - assertTrue(senderStore.outgoingRequests().isEmpty()) - assertEquals("RoboFlax2", senderStore.all().single().displayName) - assertEquals("bob", hostStore.all().single().displayName) - assertEquals(1, hostRelationshipsChanged) - assertTrue( - senderStore.all() - .single() - .permissions - .canJoinAutomatically, - ) - assertTrue( - hostStore.all() - .single() - .permissions - .canJoinAutomatically, - ) - assertFalse( - senderStore.all().single().peerId == - hostStore.all().single().peerId, - ) - } + 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/FriendRequestClientTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestClientTest.kt index 8002beda7..afcd5df0a 100644 --- 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 @@ -1,12 +1,15 @@ 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.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 @@ -25,7 +28,7 @@ import kotlinx.coroutines.runBlocking class FriendRequestClientTest { @Test - fun `Connect control request waits for remote acceptance without joining`() = + fun `libp2p control request waits for remote acceptance without joining`() = runBlocking { val server = ServerSocket( 0, @@ -61,14 +64,11 @@ class FriendRequestClientTest { } var acknowledged = false val client = FriendRequestClient( - protocolVersion = 1_075, ioDispatcher = Dispatchers.IO, ) val result = client.exchange( - target = GuestJoinTarget.Connect( - "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", - ), + target = directTarget(server), request = REQUEST, onReceived = { acknowledged = true }, ) @@ -109,15 +109,12 @@ class FriendRequestClientTest { } } val client = FriendRequestClient( - protocolVersion = 1_075, ioDispatcher = Dispatchers.IO, decisionTimeout = Duration.ofSeconds(30), ) val pending = launch { client.exchange( - target = GuestJoinTarget.Connect( - "${InetAddress.getLoopbackAddress().hostAddress}:${server.localPort}", - ), + target = directTarget(server), request = REQUEST, onReceived = {}, ) @@ -149,6 +146,18 @@ class FriendRequestClientTest { 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( 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 index 0cf4be038..7287cb41d 100644 --- 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 @@ -19,6 +19,7 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.test.StandardTestDispatcher 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) @@ -72,7 +73,8 @@ class FriendRequestServerTest { } @Test - fun `decline and direct identity mismatch never create trust`() = runTest { + 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")) @@ -102,16 +104,51 @@ class FriendRequestServerTest { request(senderCard), ).toCompletableFuture() runCurrent() - admission.answer( - admission.pending.value.single().requestId, - allow = false, - ) - runCurrent() - assertEquals(FriendControlResponse.Declined, connect.getNow(null)) + assertEquals(FriendControlResponse.Invalid, connect.getNow(null)) + 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) + assertTrue(confirmed.permissions.canJoinAutomatically) + assertTrue(hostStore.outgoingRequests().isEmpty()) + assertEquals(1, relationshipsChanged) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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 index af8e4a7f1..8f060b043 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -56,6 +57,18 @@ class FriendsViewModelTest { assertEquals(null, viewModel.state.value.safeMessage) } + @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)) @@ -349,7 +362,30 @@ class FriendsViewModelTest { browser.close() } - private fun signedLink(): String { + @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, @@ -362,6 +398,7 @@ class FriendsViewModelTest { internetDirectEnabled = false, directCandidates = emptyList(), capability = CAPABILITY, + displayName = displayName, ) val unsigned = ShareInviteCodec.unsignedBytes( payload, From ca90ad88ee1b890c4d4b9d83e812c6f18c790968 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 13:03:41 +0200 Subject: [PATCH 130/188] fix(share): make libp2p friend discovery reliable --- .../tunnel/p2p/DirectP2pNodeRuntime.java | 156 +++++++++++++++--- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 41 +++++ .../share/fabric/FabricDirectPeerRuntime.kt | 103 ++++++++++++ .../share/fabric/FabricDirectShareIngress.kt | 14 ++ .../share/fabric/FabricShareBootstrap.kt | 11 +- .../share/fabric/FabricShareBrowser.kt | 7 + .../fabric/FabricDirectPeerRuntimeTest.kt | 109 ++++++++++++ 7 files changed, 417 insertions(+), 24 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt 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 index c17406317..cbf0356f3 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -36,7 +36,9 @@ import io.libp2p.core.multiformats.MultiaddrComponent; import io.libp2p.core.multiformats.Protocol; import io.libp2p.core.multistream.StrictProtocolBinding; -import io.libp2p.discovery.MDnsDiscovery; +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; @@ -52,11 +54,13 @@ 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; @@ -73,7 +77,6 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import kotlin.Pair; -import kotlin.Unit; /** * Child-loaded implementation. No method signature may expose libp2p, Netty, @@ -98,11 +101,13 @@ final class DirectP2pNodeRuntime { private final List proxies = new CopyOnWriteArrayList<>(); private final java.util.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 MDnsDiscovery discovery; + private JmDNS discovery; private DirectP2pDiscoveryListener discoveryListener; private boolean started; private boolean closed; @@ -137,11 +142,19 @@ synchronized DirectP2pHostInfo startHost( } hostConfig = Objects.requireNonNull(config, "config"); hostHandler = Objects.requireNonNull(handler, "handler"); - host = Libp2pTunnelTransportRuntime.createHost( - privateKey, - "/ip4/0.0.0.0/tcp/0"); - installProtocols(host); - startHostIfNeeded(); + 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(); @@ -270,7 +283,7 @@ synchronized void close() { } closed = true; if (discovery != null) { - await(discovery.stop(), START_TIMEOUT_SECONDS, "stop Connect Share LAN discovery"); + discovery.stop(); discovery = null; } for (ProxyRuntime proxy : proxies) { @@ -311,16 +324,121 @@ private synchronized void startMdns() { if (discovery != null) { return; } - discovery = new MDnsDiscovery( - host, - MDNS_SERVICE, - MDNS_QUERY_INTERVAL_SECONDS, - MdnsAddressSelector.systemAddress()); - discovery.addHandler(peer -> { - onMdnsPeer(peer); - return Unit.INSTANCE; - }); - await(discovery.start(), START_TIMEOUT_SECONDS, "start Connect Share LAN discovery"); + InetAddress address = MdnsAddressSelector.systemAddress(); + JmDNS started = JmDNS.create(address); + 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(); + String peerId = host.getPeerId().toBase58(); + 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); + } + } + + 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) { 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 index 9a73a21d4..a1722a124 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -232,6 +232,47 @@ void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { 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 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 directNodeNeverAdvertisesOrAcceptsCircuitRelayAddresses() { host = new DirectP2pNode(); 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..5b547f486 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntime.kt @@ -0,0 +1,103 @@ +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 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, + displayName: () -> String, + ) : this( + node = CoreFabricDirectPeerNode( + DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + ), + dataDirectory = dataDirectory, + displayName = displayName, + ) + + private constructor( + node: FabricDirectPeerNode, + dataDirectory: Path, + displayName: () -> String, + ) : this( + browser = FabricShareBrowser(node), + ingress = FabricDirectShareIngress( + node = node, + dataDirectory = dataDirectory, + 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) { + node.publish(invitation) + } + + 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 index 974207b5b..591f47a41 100644 --- 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 @@ -47,6 +47,20 @@ class FabricDirectShareIngress private constructor( localSocket = ::openTaggedLoopbackSocket, ) + internal constructor( + node: FabricDirectNode, + dataDirectory: Path, + displayName: () -> String, + ) : this( + nodeFactory = { node }, + now = Instant::now, + accessIdentity = ShareAccessIdentityStore( + dataDirectory, + )::currentOrCreate, + displayName = displayName, + localSocket = ::openTaggedLoopbackSocket, + ) + override suspend fun start( options: ShareOptions, target: SocketAddress, 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 index 7f19c7bcf..0d6e6a75f 100644 --- 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 @@ -114,7 +114,11 @@ object FabricShareBootstrap { val gateway = ShareConnectionGateway.bind(friendRequestServer) var browser: FabricShareBrowser? = null try { - val activeBrowser = FabricShareBrowser(dataDirectory) + val directPeer = FabricDirectPeerRuntime( + dataDirectory = dataDirectory, + displayName = worldDisplayName, + ) + val activeBrowser = directPeer.browser browser = activeBrowser activeBrowser.start().leftOrNull()?.let { logger.warn(it.safeMessage) @@ -141,10 +145,7 @@ object FabricShareBootstrap { ), ) val directIngress = PersistentDirectIngress( - FabricDirectShareIngress( - dataDirectory = dataDirectory, - displayName = worldDisplayName, - ), + directPeer.ingress, ) val coordinator = ShareCoordinator( bridge = bridge, 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 index 3cfe68594..f118e17b5 100644 --- 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 @@ -116,6 +116,13 @@ class FabricShareBrowser private constructor( 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() 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..3b5da75e8 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectPeerRuntimeTest.kt @@ -0,0 +1,109 @@ +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() + } + + private class RecordingPeerNode : FabricDirectPeerNode { + private val keyPair = + KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + var discoveryStarts = 0 + var hostStarts = 0 + var publishes = 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) { + publishes++ + } + + override fun openProxy( + address: String, + shareId: String, + capability: String, + authMode: DirectP2pAuthMode, + timeout: Duration, + ): DirectP2pProxy = error("not used") + + override fun close() = Unit + } + + private companion object { + const val PEER_ID = + "12D3KooWEHeJnnq1Rfwt679bTyTxkEdtyTC8peAJWsWCxtAJ4s9y" + } +} From b5e9e861b4b3741e89ba4e36f20ccf8587976335 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 14:02:42 +0200 Subject: [PATCH 131/188] feat(share): sync friend activity and removals --- .../share/admission/AdmissionController.kt | 17 ++ .../friend/FriendControlChannelHandler.kt | 161 +++++++++++++++- .../connect/share/friend/FriendControlWire.kt | 149 ++++++++++++++ .../connect/share/friend/FriendStore.kt | 182 +++++++++++++----- .../admission/AdmissionControllerTest.kt | 25 +++ .../friend/FriendControlChannelHandlerTest.kt | 38 ++++ .../share/friend/FriendControlWireTest.kt | 50 +++++ .../connect/share/friend/FriendStoreTest.kt | 44 ++++- .../v1_21_11/ConnectShare12111Client.kt | 85 ++++++-- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 135 +++++++++++-- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../fabric/v26_2/ConnectShare262Client.kt | 85 ++++++-- .../share/fabric/v26_2/ShareJoinScreen.kt | 135 +++++++++++-- .../assets/connect-share/lang/de_de.json | 13 +- .../assets/connect-share/lang/en_us.json | 13 +- .../share/fabric/ConnectShareClient.kt | 13 +- .../share/fabric/FabricShareBootstrap.kt | 76 +++++++- .../share/fabric/FriendActivityMonitor.kt | 58 ++++++ .../connect/share/fabric/FriendRemovalSync.kt | 43 +++++ .../share/fabric/FriendRequestClient.kt | 135 +++++++++++++ .../share/fabric/FriendRequestServer.kt | 114 +++++++++++ .../share/fabric/SocialEventTracker.kt | 66 +++++++ .../share/fabric/ui/FriendsViewModel.kt | 37 +++- .../share/fabric/FriendActivityMonitorTest.kt | 56 ++++++ .../fabric/FriendPairingDirectE2ETest.kt | 86 ++++++++- .../share/fabric/FriendRemovalSyncTest.kt | 72 +++++++ .../share/fabric/FriendRequestClientTest.kt | 90 +++++++++ .../share/fabric/FriendRequestServerTest.kt | 129 +++++++++++++ .../share/fabric/SocialEventTrackerTest.kt | 55 ++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 38 +++- 31 files changed, 2089 insertions(+), 137 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitor.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRemovalSync.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityMonitorTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRemovalSyncTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt 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 index f1df32251..c587c61b4 100644 --- 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 @@ -116,6 +116,23 @@ class AdmissionController( complete(completed, answer) } + fun denyDirectPeer( + peerId: String, + purpose: AdmissionPurpose, + ): Int { + val denied = synchronized(lock) { + 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 resetShare() { val stopped = synchronized(lock) { val current = requests.values.toList() 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 index 8c5b3991d..50af91947 100644 --- 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 @@ -23,6 +23,30 @@ fun interface FriendControlServer { 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, + ) } class FriendControlChannelHandler( @@ -58,7 +82,7 @@ class FriendControlChannelHandler( if (!controlHandshake) { when ( val inspected = - FriendControlWire.inspectControlRequest(accumulated) + FriendControlWire.inspectControlMessage(accumulated) ) { FriendControlDecode.Incomplete -> return FriendControlDecode.Invalid -> { @@ -67,7 +91,7 @@ class FriendControlChannelHandler( } is FriendControlDecode.Decoded -> { - if (!inspected.value) { + if (inspected.value == FriendControlMessageKind.OTHER) { passThrough(context, accumulated) return } @@ -76,19 +100,88 @@ class FriendControlChannelHandler( } } - when (val decoded = FriendControlWire.decodeRequest(accumulated)) { + 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() - is FriendControlDecode.Decoded -> { - if (decoded.consumedBytes != accumulated.size) { - context.close() - return - } + } + } + + 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() @@ -110,7 +203,57 @@ class FriendControlChannelHandler( return } writeResponse(context, FriendControlResponse.Received) - val pending = server.handle(context.controlContext(), request) + 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() 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 index bc8f510a0..e253ff792 100644 --- 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 @@ -11,6 +11,33 @@ data class FriendControlRequest( val invitation: String, ) +data class FriendRemovalRequest( + val operationId: UUID, +) + +data class FriendActivityRequest(val requestId: UUID) + +data class FriendJoinRequest(val requestId: UUID) + +enum class FriendActivityKind { + ONLINE, + HOSTING_WORLD, + PLAYING_SERVER, +} + +data class FriendActivity( + val kind: FriendActivityKind, + val description: String? = null, +) + +enum class FriendControlMessageKind { + PAIRING, + REMOVAL, + ACTIVITY, + JOIN, + OTHER, +} + sealed interface FriendControlResponse { data object Received : FriendControlResponse @@ -23,6 +50,12 @@ sealed interface 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 } sealed interface FriendControlDecode { @@ -43,9 +76,14 @@ object FriendControlWire { 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_SERVER_ADDRESS_BYTES = 1_024 fun encodeRequest( request: FriendControlRequest, @@ -107,6 +145,78 @@ object FriendControlWire { } } + fun encodeRemoval(request: FriendRemovalRequest): ByteArray { + val output = ByteArrayOutputStream() + output.writePacket { + writeVarInt(CONTROL_REMOVAL_PACKET_ID) + writeLong(request.operationId.mostSignificantBits) + writeLong(request.operationId.leastSignificantBits) + } + return output.toByteArray() + } + + fun decodeRemoval( + bytes: ByteArray, + ): FriendControlDecode { + if (bytes.size > MAX_REQUEST_BYTES) { + return FriendControlDecode.Invalid + } + return decode(bytes) { + val control = readPacket() + ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) + val request = FriendRemovalRequest( + UUID(control.readLong(), control.readLong()), + ) + control.ensureFinished() + request + } + } + + 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 = + encodeIdRequest(CONTROL_JOIN_PACKET_ID, request.requestId) + + fun decodeJoinRequest( + bytes: ByteArray, + ): FriendControlDecode = + decodeIdRequest(bytes, CONTROL_JOIN_PACKET_ID) { + FriendJoinRequest(it) + } + + 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() @@ -128,6 +238,19 @@ object FriendControlWire { 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 { @@ -142,6 +265,16 @@ object FriendControlWire { 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) + writeString(response.activity.description.orEmpty()) + } + is FriendControlResponse.JoinAccepted -> { + write(7) + writeString(response.address) + } } } return output.toByteArray() @@ -161,6 +294,22 @@ object FriendControlWire { 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, + description = response.readString(MAX_ACTIVITY_BYTES) + .takeIf(String::isNotEmpty), + ), + ) + } + 7 -> FriendControlResponse.JoinAccepted( + response.readString(MAX_SERVER_ADDRESS_BYTES), + ) else -> invalid() } response.ensureFinished() 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 index fe3d14b6e..0f93d8cf8 100644 --- 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 @@ -59,6 +59,12 @@ data class SavedFriend( "relationshipStatus=$relationshipStatus)" } +data class PendingFriendRemoval( + val operationId: UUID, + val friend: SavedFriend, + val removedAt: Instant, +) + sealed interface FriendStoreError { val safeMessage: String @@ -85,7 +91,7 @@ sealed interface FriendStoreError { class FriendStore( private val directory: Path, ) { - private var cached: List? = null + private var cached: StoreData? = null @Synchronized fun all(): List = @@ -104,6 +110,10 @@ class FriendStore( fun relationship(peerId: String): Option = read().firstOrNull { it.peerId == peerId }.toOption() + @Synchronized + fun pendingRemovals(): List = + data().removals + @Synchronized fun accept( invitationUri: String, @@ -203,7 +213,14 @@ class FriendStore( relationshipStatus = effectiveRelationshipStatus, ) write( - current.filterNot { it.peerId == friend.peerId } + friend, + data().copy( + friends = current.filterNot { + it.peerId == friend.peerId + } + friend, + removals = data().removals.filterNot { + it.friend.peerId == friend.peerId + }, + ), ) friend } @@ -237,13 +254,45 @@ class FriendStore( } @Synchronized - fun remove(peerId: String): Boolean { + 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 } - if (remaining.size == current.size) { + val removals = data().removals.filterNot { + it.friend.peerId == peerId + } + PendingFriendRemoval( + operationId = UUID.randomUUID(), + friend = removed, + removedAt = now, + ) + write(StoreData(remaining, removals)) + return true + } + + @Synchronized + fun applyRemoteRemoval(peerId: String): Boolean { + val current = read() + if (current.none { it.peerId == peerId }) { + return false + } + write(data().copy(friends = current.filterNot { it.peerId == peerId })) + return true + } + + @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(remaining) + write(current.copy(removals = remaining)) return true } @@ -260,20 +309,23 @@ class FriendStore( updated } - private fun read(): List = + private fun read(): List = data().friends + + private fun data(): StoreData = cached ?: load().also { cached = it } - private fun load(): List { + private fun load(): StoreData { Files.createDirectories(directory) if (!Files.exists(friendsFile)) { - return emptyList() + return StoreData() } try { val root = GSON.fromJson( Files.readString(friendsFile), JsonObject::class.java, ) ?: throw IOException("Friends file is empty") - if (root.requiredInt("version") != WIRE_VERSION) { + 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") @@ -287,7 +339,17 @@ class FriendStore( if (friends.map(SavedFriend::peerId).distinct().size != friends.size) { throw IOException("Friends file contains duplicate identities") } - return friends + val removals = if (version >= 2) { + root.getAsJsonArray("pendingRemovals") + ?.map { element -> parseRemoval(element.asJsonObject) } + ?: emptyList() + } else { + emptyList() + } + if (removals.size > MAX_FRIENDS) { + throw IOException("Friends file contains too many removals") + } + return StoreData(friends, removals) } catch (exception: JsonParseException) { throw IOException("Friends file is invalid JSON", exception) } catch (exception: IllegalStateException) { @@ -297,6 +359,19 @@ class FriendStore( } } + private fun parseRemoval(json: JsonObject): PendingFriendRemoval = + PendingFriendRemoval( + operationId = UUID.fromString(json.requiredString("operationId")), + friend = parseFriend( + json.getAsJsonObject("friend") + ?: throw IOException("Removal is missing friend"), + ), + removedAt = Instant.ofEpochMilli( + json.get("removedAtEpochMillis")?.asLong + ?: throw IOException("Removal is missing time"), + ), + ) + private fun parseFriend(json: JsonObject): SavedFriend { val peerId = json.requiredString("peerId") val publicKey = json.requiredString("publicKey") @@ -346,53 +421,64 @@ class FriendStore( } private fun write(friends: List) { - require(friends.size <= MAX_FRIENDS) { + 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" + } Files.createDirectories(directory) val entries = JsonArray() - friends.sortedBy { it.displayName.lowercase() }.forEach { friend -> - entries.add(JsonObject().apply { - addProperty("peerId", friend.peerId) - addProperty("publicKey", friend.publicKeyBase64) - addProperty("shareId", friend.shareId.toString()) - addProperty("capability", friend.capability) - friend.connectAddress?.let { - addProperty("connectAddress", it) - } - addProperty("displayName", friend.displayName) - friend.minecraftUuid?.let { - addProperty("minecraftUuid", it.toString()) - } + 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( - "relationshipStatus", - friend.relationshipStatus.name, - ) - add( - "permissions", - JsonObject().apply { - addProperty( - "notifyWhenOnline", - friend.permissions.notifyWhenOnline, - ) - addProperty( - "canSeeMyWorlds", - friend.permissions.canSeeMyWorlds, - ) - addProperty( - "canJoinAutomatically", - friend.permissions.canJoinAutomatically, - ) - }, + "removedAtEpochMillis", + removal.removedAt.toEpochMilli(), ) + add("friend", removal.friend.toJson()) }) } val root = JsonObject().apply { addProperty("version", WIRE_VERSION) add("friends", entries) + add("pendingRemovals", removals) } writeAtomic(GSON.toJson(root)) - cached = friends.toList() + 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("capability", capability) + connectAddress?.let { addProperty("connectAddress", it) } + 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( + "canJoinAutomatically", + permissions.canJoinAutomatically, + ) + }, + ) } private fun writeAtomic(content: String) { @@ -452,7 +538,8 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" - private const val WIRE_VERSION = 1 + private const val MIN_WIRE_VERSION = 1 + private const val WIRE_VERSION = 2 private const val MAX_FRIENDS = 256 private const val MAX_DISPLAY_NAME_LENGTH = 64 private val GSON = Gson() @@ -483,4 +570,9 @@ class FriendStore( value.length in 16..512 && value.none(Char::isWhitespace) } + + private data class StoreData( + val friends: List = emptyList(), + val removals: List = emptyList(), + ) } 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 index d6613a9d4..0e95cf041 100644 --- 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 @@ -49,6 +49,31 @@ class AdmissionControllerTest { 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() 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 index 94c54f17c..9e7a333a4 100644 --- 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 @@ -113,6 +113,44 @@ class FriendControlChannelHandlerTest { 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()) 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 index 41d70a8e0..7118bb85d 100644 --- 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 @@ -37,6 +37,14 @@ class FriendControlWireTest { FriendControlResponse.Declined, FriendControlResponse.TimedOut, FriendControlResponse.Invalid, + FriendControlResponse.Removed, + FriendControlResponse.Activity( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + ), + FriendControlResponse.JoinAccepted("mc.hypixel.net"), ) responses.forEach { response -> @@ -49,6 +57,48 @@ class FriendControlWireTest { } } + @Test + fun `activity and join requests round trip without exposing a server address`() { + val activity = FriendActivityRequest(REQUEST_ID) + val join = FriendJoinRequest(REQUEST_ID) + + 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 `partial and oversized control frames are never accepted`() { val encoded = FriendControlWire.encodeRequest( 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 index 299367132..39ddc437e 100644 --- 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 @@ -213,13 +213,55 @@ class FriendStoreTest { val store = FriendStore(tempDir) store.accept(signedLink(), "Robin", NOW) - val removed = store.remove(PEER_ID) + 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) + + assertTrue(store.applyRemoteRemoval(PEER_ID)) + assertFalse(store.applyRemoteRemoval(PEER_ID)) + + val reloaded = FriendStore(tempDir) + assertTrue(reloaded.all().isEmpty()) + assertTrue(reloaded.pendingRemovals().isEmpty()) + } + + @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) 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 index 380eda167..8887ee8da 100644 --- 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 @@ -9,10 +9,13 @@ 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.FriendOnlineTracker +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.MinecraftStatusProbe 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 java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -58,6 +61,10 @@ class ConnectShare12111Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world", ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val joinTargetSnapshot = AtomicReference(null) val minecraftVersion = SharedConstants.getCurrentVersion().name() val dataDirectory = FabricLoader.getInstance().configDir @@ -96,6 +103,8 @@ class ConnectShare12111Client : ClientModInitializer { playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + friendJoinTarget = joinTargetSnapshot::get, bridgeFactory = { admission, admissionScope, @@ -124,7 +133,7 @@ class ConnectShare12111Client : ClientModInitializer { ) } }, - guestScreens = { parent, browser -> + guestScreens = { parent, browser, activity -> val parentScreen = parent as Screen client.execute { client.setScreen( @@ -134,6 +143,7 @@ class ConnectShare12111Client : ClientModInitializer { ConnectShareClient.friendsViewModel(), browser = browser, remotePresence = remotePresence, + friendActivity = activity, ), ) } @@ -164,9 +174,8 @@ class ConnectShare12111Client : ClientModInitializer { } } val admissionNotifications = NewAdmissionTracker() - val friendNotifications = FriendOnlineTracker() + val socialNotifications = SocialEventTracker() val admissionToastId = SystemToast.SystemToastId() - val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> val installation = @@ -181,6 +190,20 @@ class ConnectShare12111Client : ClientModInitializer { worldNameSnapshot.set( server?.worldData?.levelName ?: "Minecraft world", ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + activitySnapshot.set( + if (externalServer != null) { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServer.name, + ) + } else { + FriendActivity(FriendActivityKind.ONLINE) + }, + ) ConnectShareClient.integratedWorldChanged( worldAvailable, server, @@ -211,19 +234,18 @@ class ConnectShare12111Client : ClientModInitializer { ), ) } - friendNotifications.update( - remotePresence.state.value, - ).firstOrNull()?.let { friend -> + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + socialNotifications.update(friends.state.value).forEach { event -> SystemToast.add( minecraft.toastManager, - friendToastId, - Component.translatable( - "connect_share.notification.friend_online", - ), - Component.translatable( - "connect_share.notification.friend_online_detail", - friend.displayName, - ), + SystemToast.SystemToastId(), + event.title(), + event.detail(), ) } } @@ -245,4 +267,37 @@ class ConnectShare12111Client : ClientModInitializer { const val PRESENCE_REFRESH_MILLIS = 30_000L val LOGGER: Logger = Logger.getLogger("Connect") } + + 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, + ) + } } 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 index ff8b50e91..5f8136715 100644 --- 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 @@ -4,9 +4,12 @@ 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.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.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -22,6 +25,7 @@ import kotlinx.coroutines.withContext import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.Checkbox 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 @@ -37,6 +41,7 @@ class ShareJoinScreen( 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 @@ -72,6 +77,7 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -92,6 +98,7 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -136,10 +143,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) val state = friends.state.value @@ -154,10 +161,10 @@ class ShareJoinScreen( ) if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.empty"), 82, - ).setMaxWidth(CONTENT_WIDTH), + ), ) } incoming.forEachIndexed { index, request -> @@ -169,7 +176,11 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.incoming_request", + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, request.displayName, request.ingress.displayName(), ), @@ -233,11 +244,33 @@ class ShareJoinScreen( saved.forEachIndexed { index, friend -> val y = 58 + (incoming.size + outgoing.size + index) * 26 + val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), ) + if (actionWidth > 0) { + addRenderableWidget( + Button.builder( + Component.translatable( + if (friend.canRequestJoin) { + "connect_share.friends.request_join" + } else { + "connect_share.join.join" + }, + ), + ) { + if (friend.canRequestJoin) requestToJoin(friend.peerId) + else joinSaved(friend.peerId) + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + } addRenderableWidget( Button.builder( Component.translatable( @@ -300,10 +333,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.add_description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) nameBox = addRenderableWidget( EditBox( @@ -467,7 +500,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.auto_join"), font, - ).pos(width / 2 - 155, 104) + ).pos(width / 2 - 155, 126) .selected(friend.permissions.canJoinAutomatically) .tooltip( Tooltip.create( @@ -478,10 +511,18 @@ class ShareJoinScreen( ) .build(), ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 138) + centered(Component.literal(message), 154) .setMaxWidth(CONTENT_WIDTH), ) } @@ -500,8 +541,7 @@ class ShareJoinScreen( friend.peerId, FriendPermissions( notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, + canSeeMyWorlds = shareWorlds.selected(), canJoinAutomatically = autoJoin.selected(), ), @@ -541,12 +581,12 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable( "connect_share.friends.remove_confirm.message", ), 58, - ).setMaxWidth(CONTENT_WIDTH), + ), ) addRenderableWidget( Button.builder( @@ -623,6 +663,43 @@ class ShareJoinScreen( } } + private fun requestToJoin(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + val target = friends.routeFriendControl( + peerId, + browser, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() + if (target == null) { + joining = false + safeMessage = Component.translatable( + "connect_share.friends.friend_unreachable", + ).string + rebuildWidgets() + return@launch + } + ConnectShareClient.friendRequestClient().requestJoin( + target, + FriendJoinRequest(UUID.randomUUID()), + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = { address -> + connect(GuestJoinTarget.Connect(address)) + }, + ) + } + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -679,7 +756,7 @@ class ShareJoinScreen( val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, - authMode = authMode(), + authMode = DirectP2pAuthMode.OFFLINE, ) val target = targetResult.getOrNull() if (target == null) { @@ -866,6 +943,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + friend.onlineViaLan -> Component.translatable( "connect_share.friends.ready_lan", @@ -880,6 +964,12 @@ class ShareJoinScreen( friend.worldName ?: "", ) + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + friend.connectAvailable -> Component.translatable( "connect_share.friends.saved_connect", @@ -943,6 +1033,17 @@ class ShareJoinScreen( ) } + private fun centeredWrapped( + message: Component, + y: Int, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + private enum class Mode { FRIENDS, ADD, 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 index e85b94ae9..d52af6fda 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", @@ -54,6 +54,7 @@ "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…", @@ -63,6 +64,10 @@ "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.playing_server": "%s · spielt auf %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", @@ -91,6 +96,12 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index fe750872d..48b8239f4 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", + "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "connect_share.friends.copying_my_link": "Creating friend link…", @@ -54,6 +54,7 @@ "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "connect_share.friends.retry_request": "Retry", "connect_share.friends.request_sending": "Sending…", @@ -63,6 +64,10 @@ "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", + "connect_share.friends.request_join": "Request", + "connect_share.friends.playing_server": "%s · playing on %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 friend", "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", @@ -91,6 +96,12 @@ "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 online. Open Friends 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", 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 index 0340b73f2..181a07a99 100644 --- 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 @@ -9,10 +9,13 @@ 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.FriendOnlineTracker +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.MinecraftStatusProbe 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 java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -58,6 +61,10 @@ class ConnectShare262Client : ClientModInitializer { client.singleplayerServer?.worldData?.levelName ?: "Minecraft world", ) + val activitySnapshot = AtomicReference( + FriendActivity(FriendActivityKind.ONLINE), + ) + val joinTargetSnapshot = AtomicReference(null) val minecraftVersion = SharedConstants.getCurrentVersion().name() val dataDirectory = FabricLoader.getInstance().configDir @@ -96,6 +103,8 @@ class ConnectShare262Client : ClientModInitializer { playerCount = playerCountSnapshot::get, worldDisplayName = worldNameSnapshot::get, playerDisplayName = { client.user.name }, + friendActivity = activitySnapshot::get, + friendJoinTarget = joinTargetSnapshot::get, bridgeFactory = { admission, admissionScope, @@ -124,7 +133,7 @@ class ConnectShare262Client : ClientModInitializer { ) } }, - guestScreens = { parent, browser -> + guestScreens = { parent, browser, activity -> val parentScreen = parent as Screen client.execute { client.gui.setScreen( @@ -134,6 +143,7 @@ class ConnectShare262Client : ClientModInitializer { ConnectShareClient.friendsViewModel(), browser = browser, remotePresence = remotePresence, + friendActivity = activity, ), ) } @@ -164,9 +174,8 @@ class ConnectShare262Client : ClientModInitializer { } } val admissionNotifications = NewAdmissionTracker() - val friendNotifications = FriendOnlineTracker() + val socialNotifications = SocialEventTracker() val admissionToastId = SystemToast.SystemToastId() - val friendToastId = SystemToast.SystemToastId() ClientTickEvents.END_CLIENT_TICK.register { minecraft -> val installation = @@ -181,6 +190,20 @@ class ConnectShare262Client : ClientModInitializer { worldNameSnapshot.set( server?.worldData?.levelName ?: "Minecraft world", ) + val currentServer = minecraft.currentServer + val externalServer = currentServer + ?.takeIf { !worldAvailable } + joinTargetSnapshot.set(externalServer?.ip) + activitySnapshot.set( + if (externalServer != null) { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServer.name, + ) + } else { + FriendActivity(FriendActivityKind.ONLINE) + }, + ) ConnectShareClient.integratedWorldChanged( worldAvailable, server, @@ -211,19 +234,18 @@ class ConnectShare262Client : ClientModInitializer { ), ) } - friendNotifications.update( - remotePresence.state.value, - ).firstOrNull()?.let { friend -> + val friends = installation.friendsViewModel + friends.updateIncoming( + installation.viewModel.state.value.pendingAdmissions, + ) + friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(installation.friendActivity.state.value) + socialNotifications.update(friends.state.value).forEach { event -> SystemToast.add( minecraft.gui.toastManager(), - friendToastId, - Component.translatable( - "connect_share.notification.friend_online", - ), - Component.translatable( - "connect_share.notification.friend_online_detail", - friend.displayName, - ), + SystemToast.SystemToastId(), + event.title(), + event.detail(), ) } } @@ -245,4 +267,37 @@ class ConnectShare262Client : ClientModInitializer { const val PRESENCE_REFRESH_MILLIS = 30_000L val LOGGER: Logger = Logger.getLogger("Connect") } + + 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, + ) + } } 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 index 712194978..0bd775d1b 100644 --- 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 @@ -4,9 +4,12 @@ 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.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.FriendsViewModel import com.minekube.connect.share.friend.FriendPermissions @@ -22,6 +25,7 @@ import kotlinx.coroutines.withContext import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.components.Checkbox 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 @@ -37,6 +41,7 @@ class ShareJoinScreen( 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 @@ -72,6 +77,7 @@ class ShareJoinScreen( } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -92,6 +98,7 @@ class ShareJoinScreen( super.tick() friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) + friends.updateActivities(friendActivity.state.value) friends.updateIncoming( ConnectShareClient.viewModel().state.value.pendingAdmissions, ) @@ -136,10 +143,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) val state = friends.state.value @@ -154,10 +161,10 @@ class ShareJoinScreen( ) if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.empty"), 82, - ).setMaxWidth(CONTENT_WIDTH), + ), ) } incoming.forEachIndexed { index, request -> @@ -169,7 +176,11 @@ class ShareJoinScreen( 174, 20, Component.translatable( - "connect_share.friends.incoming_request", + if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { + "connect_share.friends.incoming_request" + } else { + "connect_share.friends.incoming_join_request" + }, request.displayName, request.ingress.displayName(), ), @@ -233,11 +244,33 @@ class ShareJoinScreen( saved.forEachIndexed { index, friend -> val y = 58 + (incoming.size + outgoing.size + index) * 26 + val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 addRenderableWidget( - Button.builder(friendLabel(friend)) { - joinSaved(friend.peerId) - }.bounds(width / 2 - 155, y, 242, 20).build(), + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), ) + if (actionWidth > 0) { + addRenderableWidget( + Button.builder( + Component.translatable( + if (friend.canRequestJoin) { + "connect_share.friends.request_join" + } else { + "connect_share.join.join" + }, + ), + ) { + if (friend.canRequestJoin) requestToJoin(friend.peerId) + else joinSaved(friend.peerId) + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + } addRenderableWidget( Button.builder( Component.translatable( @@ -300,10 +333,10 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable("connect_share.friends.add_description"), 34, - ).setMaxWidth(CONTENT_WIDTH), + ), ) nameBox = addRenderableWidget( EditBox( @@ -467,7 +500,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.auto_join"), font, - ).pos(width / 2 - 155, 104) + ).pos(width / 2 - 155, 126) .selected(friend.permissions.canJoinAutomatically) .tooltip( Tooltip.create( @@ -478,10 +511,18 @@ class ShareJoinScreen( ) .build(), ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 138) + centered(Component.literal(message), 154) .setMaxWidth(CONTENT_WIDTH), ) } @@ -500,8 +541,7 @@ class ShareJoinScreen( friend.peerId, FriendPermissions( notifyWhenOnline = notify.selected(), - canSeeMyWorlds = - friend.permissions.canSeeMyWorlds, + canSeeMyWorlds = shareWorlds.selected(), canJoinAutomatically = autoJoin.selected(), ), @@ -541,12 +581,12 @@ class ShareJoinScreen( ), ) addRenderableWidget( - centered( + centeredWrapped( Component.translatable( "connect_share.friends.remove_confirm.message", ), 58, - ).setMaxWidth(CONTENT_WIDTH), + ), ) addRenderableWidget( Button.builder( @@ -623,6 +663,43 @@ class ShareJoinScreen( } } + private fun requestToJoin(peerId: String) { + if (joining) return + joining = true + joiningPeerId = peerId + reciprocalPairing = false + safeMessage = null + refresh() + scope?.launch { + val target = friends.routeFriendControl( + peerId, + browser, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() + if (target == null) { + joining = false + safeMessage = Component.translatable( + "connect_share.friends.friend_unreachable", + ).string + rebuildWidgets() + return@launch + } + ConnectShareClient.friendRequestClient().requestJoin( + target, + FriendJoinRequest(UUID.randomUUID()), + ).fold( + ifLeft = { failure -> + joining = false + safeMessage = failure.safeMessage + rebuildWidgets() + }, + ifRight = { address -> + connect(GuestJoinTarget.Connect(address)) + }, + ) + } + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -679,7 +756,7 @@ class ShareJoinScreen( val targetResult = friends.routeOutgoing( peerId = peerId, browser = browser, - authMode = authMode(), + authMode = DirectP2pAuthMode.OFFLINE, ) val target = targetResult.getOrNull() if (target == null) { @@ -865,6 +942,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + friend.onlineViaLan -> Component.translatable( "connect_share.friends.ready_lan", @@ -879,6 +963,12 @@ class ShareJoinScreen( friend.worldName ?: "", ) + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + friend.connectAvailable -> Component.translatable( "connect_share.friends.saved_connect", @@ -942,6 +1032,17 @@ class ShareJoinScreen( ) } + private fun centeredWrapped( + message: Component, + y: Int, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + private enum class Mode { FRIENDS, ADD, 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 index e85b94ae9..d52af6fda 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Bestätigte Freunde erscheinen, sobald ihre Welt bereit ist. Gesendete Anfragen bleiben bis zur Annahme privat.", + "connect_share.friends.description": "Spielbereite Freunde erscheinen hier.", "connect_share.friends.copy_my_link": "Meinen Freundeslink kopieren", "connect_share.friends.copy_my_link.tooltip": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", "connect_share.friends.copying_my_link": "Freundeslink wird erstellt…", @@ -54,6 +54,7 @@ "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…", @@ -63,6 +64,10 @@ "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.playing_server": "%s · spielt auf %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", @@ -91,6 +96,12 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index fe750872d..48b8239f4 100644 --- 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 @@ -45,7 +45,7 @@ "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.description": "Confirmed friends appear when their world is ready. Sent requests stay private until accepted.", + "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", "connect_share.friends.copying_my_link": "Creating friend link…", @@ -54,6 +54,7 @@ "connect_share.friends.empty": "No friends or sent requests yet. Paste a friend's link to send one.", "connect_share.friends.outgoing_request": "Request to %s", "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", "connect_share.friends.outgoing_request_active": "%s · request in progress", "connect_share.friends.retry_request": "Retry", "connect_share.friends.request_sending": "Sending…", @@ -63,6 +64,10 @@ "connect_share.friends.request_accepted": "%s accepted your friend request.", "connect_share.friends.cancel_request": "Cancel", "connect_share.friends.manage": "Manage", + "connect_share.friends.request_join": "Request", + "connect_share.friends.playing_server": "%s · playing on %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 friend", "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", "connect_share.friends.name": "Friend's name", @@ -91,6 +96,12 @@ "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 online. Open Friends 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", 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 index 5cb772d48..677b672b9 100644 --- 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 @@ -10,7 +10,11 @@ fun interface ConnectShareScreenFactory { } fun interface ConnectShareGuestScreenFactory { - fun open(parent: Any, browser: FabricShareBrowser) + fun open( + parent: Any, + browser: FabricShareBrowser, + activity: FriendActivityMonitor, + ) } data class ConnectShareInstallation( @@ -25,6 +29,7 @@ data class ConnectShareInstallation( val controlPlane: ConnectControlPlane, val directControlPlane: DirectControlPlane, val browser: FabricShareBrowser, + val friendActivity: FriendActivityMonitor, val gateway: ShareConnectionGateway, val ownConnectAddress: String, val screens: ConnectShareScreenFactory, @@ -65,7 +70,11 @@ object ConnectShareClient { @JvmStatic fun openJoinScreen(parent: Any) { installation?.let { installed -> - installed.guestScreens.open(parent, installed.browser) + installed.guestScreens.open( + parent, + installed.browser, + installed.friendActivity, + ) } } 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 index 0d6e6a75f..155b5b1c7 100644 --- 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 @@ -13,16 +13,25 @@ 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.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.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore +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.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.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient @@ -37,6 +46,10 @@ object FabricShareBootstrap { playerCount: () -> Int, worldDisplayName: () -> String = { "Minecraft world" }, playerDisplayName: () -> String? = { null }, + friendActivity: () -> FriendActivity = { + FriendActivity(FriendActivityKind.ONLINE) + }, + friendJoinTarget: () -> String? = { null }, bridgeFactory: ( AdmissionController, @@ -103,13 +116,14 @@ object FabricShareBootstrap { connectAddress = { ownConnectAddress }, ) val friendCardReceiver = FriendCardReceiver(friendStore) - val friendsViewModel = FriendsViewModel(friendStore) val friendRequestServer = FriendRequestServer( scope = scope, admission = admission, issuer = friendCardIssuer, receiver = friendCardReceiver, friendStore = friendStore, + activity = friendActivity, + joinTarget = friendJoinTarget, ) val gateway = ShareConnectionGateway.bind(friendRequestServer) var browser: FabricShareBrowser? = null @@ -185,6 +199,63 @@ object FabricShareBootstrap { 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(removal.operationId), + ) + }, + ) + } + val friendsViewModel = FriendsViewModel(friendStore) { + scope.launch(Dispatchers.IO) { + removalSync.sync() + } + } + 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, @@ -222,6 +293,7 @@ object FabricShareBootstrap { controlPlane = controlPlane, directControlPlane = directControlPlane, browser = activeBrowser, + friendActivity = activityMonitor, gateway = gateway, ownConnectAddress = ownConnectAddress, screens = screens, @@ -256,6 +328,8 @@ object FabricShareBootstrap { 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 class FabricConnectLogger( 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/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 index edc1c68a4..5f00a80f1 100644 --- 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 @@ -3,10 +3,15 @@ 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 @@ -100,6 +105,15 @@ class FriendRequestClient( FriendControlResponse.Invalid -> outcome = FriendRequestFailure.InvalidResponse.left() + + FriendControlResponse.Removed -> + outcome = + FriendRequestFailure.InvalidResponse.left() + + is FriendControlResponse.Activity, + is FriendControlResponse.JoinAccepted, + -> outcome = + FriendRequestFailure.InvalidResponse.left() } } outcome @@ -116,6 +130,127 @@ class FriendRequestClient( } } + 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, + -> 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 -> response.address.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 { 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 index d5c8f465c..51ab6a691 100644 --- 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 @@ -9,6 +9,11 @@ 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.FriendRelationshipStatus import com.minekube.connect.share.friend.FriendStore @@ -32,6 +37,10 @@ class FriendRequestServer( 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 joinTarget: () -> String? = { null }, ) : FriendControlServer { override fun handle( context: FriendControlContext, @@ -52,6 +61,111 @@ class FriendRequestServer( 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 { + admission.denyDirectPeer( + peerId, + AdmissionPurpose.FRIEND, + ) + if (friendStore.applyRemoteRemoval(peerId)) { + 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 visible = if (friend.permissions.canSeeMyWorlds) { + activity() + } else { + FriendActivity(FriendActivityKind.ONLINE) + } + FriendControlResponse.Activity(visible) + } + + override fun handleJoin( + context: FriendControlContext, + request: FriendJoinRequest, + ): CompletionStage = launchResponse { + val friend = authenticatedFriend(context) + ?: return@launchResponse FriendControlResponse.Invalid + if (activity().kind != FriendActivityKind.PLAYING_SERVER) { + return@launchResponse FriendControlResponse.Invalid + } + val identity = AdmissionIdentity.UnverifiedOffline( + name = friend.displayName, + uuid = friend.shareId, + connectionId = "friend-join:${request.requestId}", + ingress = context.ingress, + directPeerId = context.directPeerId, + ) + when (admission.request(identity, AdmissionPurpose.JOIN)) { + AdmissionAnswer.ALLOW -> joinTarget() + ?.takeIf(String::isNotBlank) + ?.let(FriendControlResponse::JoinAccepted) + ?: 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, 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..6aec8793c --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/SocialEventTracker.kt @@ -0,0 +1,66 @@ +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.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/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt index 34afb59a6..2a1e0be88 100644 --- 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 @@ -18,6 +18,8 @@ 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.tunnel.p2p.DirectP2pAuthMode import java.time.Instant import java.util.UUID @@ -33,6 +35,10 @@ data class FriendSummary( 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, ) data class OutgoingFriendRequestSummary( @@ -44,6 +50,7 @@ data class IncomingFriendRequestSummary( val requestId: UUID, val displayName: String, val ingress: Ingress, + val purpose: AdmissionPurpose, ) data class FriendsUiState( @@ -55,9 +62,11 @@ data class FriendsUiState( class FriendsViewModel( private val store: FriendStore, + 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()) @@ -123,6 +132,9 @@ class FriendsViewModel( }, ifRight = { removed -> refresh() + if (removed) { + onRemovalQueued() + } removed }, ) @@ -145,10 +157,15 @@ class FriendsViewModel( refresh(preserveSafeMessage = true) } + fun updateActivities(activity: Map) { + if (activities == activity) return + activities = activity + refresh(preserveSafeMessage = true) + } + fun updateIncoming(pending: List) { val next = pending .asSequence() - .filter { it.purpose == AdmissionPurpose.FRIEND } .map { IncomingFriendRequestSummary( requestId = it.requestId, @@ -160,6 +177,7 @@ class FriendsViewModel( is AdmissionIdentity.UnverifiedOffline -> identity.ingress }, + purpose = it.purpose, ) } .toList() @@ -192,6 +210,16 @@ class FriendsViewModel( 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() } @@ -254,6 +282,7 @@ class FriendsViewModel( private fun SavedFriend.summary(): FriendSummary { val remote = remotePresence[peerId] ?.takeIf { it.online } + val activity = activities[peerId] return FriendSummary( peerId = peerId, displayName = displayName, @@ -262,6 +291,12 @@ class FriendsViewModel( onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, onlineViaConnect = remote?.route == ShareRoute.CONNECT, worldName = remote?.description, + activityKind = activity?.kind, + activityDescription = activity?.description, + canRequestJoin = + activity?.kind == FriendActivityKind.PLAYING_SERVER, + canJoinNow = remote != null && + activity?.kind != FriendActivityKind.PLAYING_SERVER, ) } 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/FriendPairingDirectE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendPairingDirectE2ETest.kt index 40f455d6f..07bfd97ff 100644 --- 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 @@ -6,6 +6,11 @@ import com.minekube.connect.share.ShareOptions import com.minekube.connect.share.admission.AdmissionController 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 @@ -64,6 +69,13 @@ class FriendPairingDirectE2ETest { friendStore = hostStore, now = { now }, ioDispatcher = Dispatchers.IO, + activity = { + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ) + }, + joinTarget = { "mc.hypixel.net" }, ) try { @@ -102,17 +114,18 @@ class FriendPairingDirectE2ETest { 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 = FriendRequestClient( - ioDispatcher = Dispatchers.IO, - connectTimeout = Duration.ofSeconds(3), - decisionTimeout = Duration.ofSeconds(5), - ), + requestClient = requestClient, now = { now }, ioDispatcher = Dispatchers.IO, ) @@ -155,6 +168,69 @@ class FriendPairingDirectE2ETest { "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()), + ) + } + 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( + "mc.hypixel.net", + requestedJoin.await().getOrNull(), + ) + + 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(removal.operationId), + ).isRight(), + ) + senderStore.acknowledgeRemoval(removal.operationId) + + assertTrue(hostStore.all().isEmpty()) + assertTrue(senderStore.all().isEmpty()) + assertTrue(senderStore.pendingRemovals().isEmpty()) } finally { browser.close() direct.close() 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 index afcd5df0a..2ff84f62c 100644 --- 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 @@ -6,6 +6,11 @@ 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 @@ -127,6 +132,91 @@ class FriendRequestClientTest { 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()) + val server = responseServer( + FriendControlWire.encodeJoinRequest(request), + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + ) + + val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) + .requestJoin(directTarget(server), request) + + assertEquals("mc.hypixel.net", 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) { 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 index 7287cb41d..2c7c72df2 100644 --- 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 @@ -7,6 +7,11 @@ 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 java.nio.file.Path import java.time.Instant @@ -149,6 +154,130 @@ class FriendRequestServerTest { 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 server = FriendRequestServer( + scope = backgroundScope, + admission = admission(), + issuer = issuer("host"), + receiver = FriendCardReceiver(hostStore), + friendStore = hostStore, + now = { NOW }, + ioDispatcher = StandardTestDispatcher(testScheduler), + ) + val context = FriendControlContext( + ingress = Ingress.DIRECT_LAN, + directPeerId = senderPeerId, + ) + val removal = FriendRemovalRequest(UUID.randomUUID()) + + assertEquals( + FriendControlResponse.Removed, + server.handleRemoval(context, removal).await(), + ) + assertEquals( + FriendControlResponse.Removed, + server.handleRemoval(context, removal).await(), + ) + assertTrue(hostStore.all().isEmpty()) + assertTrue(hostStore.pendingRemovals().isEmpty()) + } + + @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()), + ).toCompletableFuture() + runCurrent() + + val pending = admission.pending.value.single() + assertEquals(AdmissionPurpose.JOIN, pending.purpose) + assertEquals("bob", pending.identity.name) + admission.answer(pending.requestId, allow = true) + runCurrent() + + assertEquals( + FriendControlResponse.JoinAccepted("mc.hypixel.net"), + response.getNow(null), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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..e2e424cd0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/SocialEventTrackerTest.kt @@ -0,0 +1,55 @@ +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()), + ) + } + + 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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt index 8f060b043..9af99d374 100644 --- 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 @@ -16,6 +16,8 @@ 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 @@ -94,7 +96,7 @@ class FriendsViewModelTest { } @Test - fun `title friends state exposes only incoming friend approvals`() { + fun `friends state exposes both friend and join approvals`() { val viewModel = FriendsViewModel(FriendStore(tempDir)) val friendRequestId = UUID.randomUUID() val joinRequestId = UUID.randomUUID() @@ -124,10 +126,14 @@ class FriendsViewModelTest { ), ) - val incoming = viewModel.state.value.incomingRequests.single() - assertEquals(friendRequestId, incoming.requestId) - assertEquals("bob", incoming.displayName) - assertEquals(Ingress.CONNECT, incoming.ingress) + 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()) } @@ -285,6 +291,28 @@ class FriendsViewModelTest { assertEquals("Robin's Remote World", online.worldName) } + @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 `joining a saved friend does not expose its stored capability`() = runTest { val link = signedLink() From 8e5ad6f1f569ff9f4fa74ac00160b0b9ee2c3a63 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 16:45:26 +0200 Subject: [PATCH 132/188] feat(share): request joins to friends worlds --- share/AGENTS.md | 43 ++++++ .../connect/share/ShareConnectionGateway.kt | 10 ++ .../share/admission/AdmissionController.kt | 32 +++++ .../connect/share/friend/FriendControlWire.kt | 47 ++++++- .../share/ShareConnectionGatewayTest.kt | 9 ++ .../admission/AdmissionControllerTest.kt | 46 +++++++ .../share/friend/FriendControlWireTest.kt | 9 +- .../mixin/ServerLoginPacketListenerMixin.java | 10 ++ .../v1_21_11/ConnectShare12111Client.kt | 22 +-- .../fabric/v1_21_11/FriendCardNetworking.kt | 1 + .../v1_21_11/Minecraft12111LoginBridge.kt | 16 +++ .../share/fabric/v1_21_11/ShareJoinScreen.kt | 34 ++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../mixin/ServerLoginPacketListenerMixin.java | 10 ++ .../fabric/v26_2/ConnectShare262Client.kt | 22 +-- .../fabric/v26_2/FriendCardNetworking.kt | 1 + .../fabric/v26_2/Minecraft262LoginBridge.kt | 16 +++ .../share/fabric/v26_2/ShareJoinScreen.kt | 34 ++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../FabricDirectAuthenticationPolicy.kt | 13 ++ .../share/fabric/FriendActivityResolver.kt | 23 ++++ .../connect/share/fabric/FriendCardIssuer.kt | 11 +- .../share/fabric/FriendRequestClient.kt | 15 ++- .../share/fabric/FriendRequestServer.kt | 26 +++- .../share/fabric/SocialEventTracker.kt | 8 ++ .../share/fabric/ui/FriendsViewModel.kt | 7 +- .../FabricDirectAuthenticationPolicyTest.kt | 21 +++ .../fabric/FriendActivityResolverTest.kt | 47 +++++++ .../share/fabric/FriendCardIssuerTest.kt | 1 + .../fabric/FriendPairingDirectE2ETest.kt | 73 ++++++++-- .../share/fabric/FriendRequestClientTest.kt | 34 ++++- .../share/fabric/FriendRequestServerTest.kt | 59 ++++++++- .../share/fabric/PrismFriendJoinE2ETest.kt | 125 ++++++++++++++++++ .../share/fabric/SocialEventTrackerTest.kt | 23 ++++ .../share/fabric/ui/FriendsViewModelTest.kt | 33 +++++ 37 files changed, 826 insertions(+), 67 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendActivityResolverTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt diff --git a/share/AGENTS.md b/share/AGENTS.md index 090dd4704..9e1d69952 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -64,3 +64,46 @@ redesigned for Kotlin. cancellation. - For retries or parallel operators, use deterministic virtual-time tests; no real sleeps. + +## Prism Two-Client E2E + +- Prism can drive the live flow without UI automation. Launch the host with + `prismlauncher --launch --profile --world ` and a + distinct offline guest with + `prismlauncher --launch --offline --server `. + `--offline ` is authoritative; editing `InstanceAccountId` while Prism + runs is not, because Prism rewrites it. +- Prove the flow in layers: mDNS discovery, authenticated friend activity, + Minecraft status, then a real login whose host log contains + ` joined the game`. Control-plane reachability or a status response does + not prove that the world is joinable. `dns-sd -B + _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are + useful diagnostics for discovery and live `ShareState`/transport objects. +- Run only one Gradle invocation at a time in a worktree. Concurrent test tasks + share `build/test-results` and can delete one another's in-progress binary + results, producing a false infrastructure failure. +- A `DirectP2pProxy` target is currently one-shot. A status probe consumes it; + open a separate target for gameplay and keep that target alive until the + Minecraft connection finishes. Never reuse the friend-control target for a + status probe or login. +- 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`; otherwise friends see a world that cannot yet + accept them. +- `ShareConnectionGateway` installs Minecraft's captured Netty initializer + after its accepted channel is already active. Any change to that dispatch + must preserve a focused test proving newly installed handlers receive the + required active lifecycle before the first Minecraft bytes. +- A direct session negotiated as `OFFLINE` must create Minecraft's standard + offline profile in `handleHello`, before vanilla starts Mojang session + authentication. Otherwise an offline Prism friend is rejected as "Invalid + session" before admission runs. `ONLINE` direct sessions must never silently + downgrade. +- For no-click friend-request E2E, temporarily enable automatic joins only for + the confirmed test friend, send the real libp2p join request, and restore the + permission afterwards. Keep machine-specific instance paths and credentials + in environment variables, never in committed tests or scripts. +- `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, + supply `LIVE_DATA`, `LIVE_PORT_FILE`, and `LIVE_HOST_LOG`, then launch the + guest against the port written to `LIVE_PORT_FILE`. The test succeeds only + after the host logs a new ` joined the game` line. 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 index 07dafaa00..6e57abf04 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -153,7 +153,15 @@ class ShareConnectionGateway private constructor( } 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) } } @@ -180,5 +188,7 @@ class ShareConnectionGateway private constructor( "connect-share-minecraft-dispatch" private const val MINECRAFT_INITIALIZER = "connect-share-minecraft-initializer" + private const val MINECRAFT_LIFECYCLE_REPLAY = + "connect-share-minecraft-lifecycle-replay" } } 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 index c587c61b4..8dcaa6da4 100644 --- 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 @@ -24,6 +24,7 @@ class AdmissionController( private val lock = Any() private val requests = linkedMapOf() private val authenticatedApprovals = mutableSetOf() + private val preapprovedJoins = mutableSetOf() private val mutablePending = MutableStateFlow>(emptyList()) val pending: StateFlow> = mutablePending.asStateFlow() @@ -49,6 +50,17 @@ class AdmissionController( ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } + if (purpose == AdmissionPurpose.JOIN) { + val preapproved = preapprovedJoins.firstOrNull { + it.matches(identity) + } + if (preapproved != null) { + preapprovedJoins.remove(preapproved) + return@synchronized RequestLookup.Immediate( + AdmissionAnswer.ALLOW, + ) + } + } if ( purpose == AdmissionPurpose.JOIN && autoApprove(identity) @@ -121,6 +133,7 @@ class AdmissionController( purpose: AdmissionPurpose, ): Int { val denied = synchronized(lock) { + preapprovedJoins.removeIf { it.directPeerId == peerId } val matches = requests.entries.filter { entry -> entry.value.pending.purpose == purpose && entry.value.pending.identity.directPeerId == peerId @@ -138,6 +151,7 @@ class AdmissionController( val current = requests.values.toList() requests.clear() authenticatedApprovals.clear() + preapprovedJoins.clear() publishPending() current } @@ -146,6 +160,15 @@ class AdmissionController( } } + fun approveNextJoin(identity: AdmissionIdentity) { + synchronized(lock) { + preapprovedJoins += PreapprovedJoin( + directPeerId = identity.directPeerId, + minecraftUuid = identity.uuid, + ) + } + } + private fun startTimeout(request: PendingRequest) { val timeoutJob = scope.launch { delay(timeout) @@ -222,6 +245,15 @@ class AdmissionController( ) : AdmissionKey } + private data class PreapprovedJoin( + val directPeerId: String?, + val minecraftUuid: UUID, + ) { + fun matches(identity: AdmissionIdentity): Boolean = + (directPeerId != null && directPeerId == identity.directPeerId) || + minecraftUuid == identity.uuid + } + private class PendingRequest( val key: AdmissionKey, val pending: PendingAdmission, 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 index e253ff792..e8d2a1f3b 100644 --- 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 @@ -17,7 +17,11 @@ data class FriendRemovalRequest( data class FriendActivityRequest(val requestId: UUID) -data class FriendJoinRequest(val requestId: UUID) +data class FriendJoinRequest( + val requestId: UUID, + val playerName: String, + val playerUuid: UUID, +) enum class FriendActivityKind { ONLINE, @@ -56,6 +60,8 @@ sealed interface FriendControlResponse { data class Activity(val activity: FriendActivity) : FriendControlResponse data class JoinAccepted(val address: String) : FriendControlResponse + + data object SharedWorldJoinAccepted : FriendControlResponse } sealed interface FriendControlDecode { @@ -84,6 +90,7 @@ object FriendControlWire { private const val MAX_INVITATION_BYTES = 32_768 private const val MAX_ACTIVITY_BYTES = 512 private const val MAX_SERVER_ADDRESS_BYTES = 1_024 + private const val MAX_PLAYER_NAME_BYTES = 64 fun encodeRequest( request: FriendControlRequest, @@ -182,15 +189,41 @@ object FriendControlWire { FriendActivityRequest(it) } - fun encodeJoinRequest(request: FriendJoinRequest): ByteArray = - encodeIdRequest(CONTROL_JOIN_PACKET_ID, request.requestId) + 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 = - decodeIdRequest(bytes, CONTROL_JOIN_PACKET_ID) { - FriendJoinRequest(it) + ): 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() @@ -275,6 +308,7 @@ object FriendControlWire { write(7) writeString(response.address) } + FriendControlResponse.SharedWorldJoinAccepted -> write(8) } } return output.toByteArray() @@ -310,6 +344,7 @@ object FriendControlWire { 7 -> FriendControlResponse.JoinAccepted( response.readString(MAX_SERVER_ADDRESS_BYTES), ) + 8 -> FriendControlResponse.SharedWorldJoinAccepted else -> invalid() } response.ensureFinished() 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 index 20cdab1c9..80bae656c 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -85,11 +85,19 @@ class ShareConnectionGatewayTest { 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, @@ -128,6 +136,7 @@ class ShareConnectionGatewayTest { ORDINARY_MINECRAFT_BYTES, received.get(2, TimeUnit.SECONDS), ) + assertEquals(Unit, activated.get(2, TimeUnit.SECONDS)) } Socket().use { socket -> 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 index 0e95cf041..e0acd2b17 100644 --- 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 @@ -253,6 +253,52 @@ class AdmissionControllerTest { 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 also authorizes Connect fallback by player UUID`() = 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( + authenticated("RoboFlax2", AUTHENTICATED_UUID), + ), + ) + assertTrue(controller.pending.value.isEmpty()) + } + private fun kotlinx.coroutines.test.TestScope.controller( connectedCount: () -> Int = { 0 }, maxGuests: () -> Int = { 8 }, 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 index 7118bb85d..c210904ac 100644 --- 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 @@ -45,6 +45,7 @@ class FriendControlWireTest { ), ), FriendControlResponse.JoinAccepted("mc.hypixel.net"), + FriendControlResponse.SharedWorldJoinAccepted, ) responses.forEach { response -> @@ -60,7 +61,11 @@ class FriendControlWireTest { @Test fun `activity and join requests round trip without exposing a server address`() { val activity = FriendActivityRequest(REQUEST_ID) - val join = FriendJoinRequest(REQUEST_ID) + val join = FriendJoinRequest( + requestId = REQUEST_ID, + playerName = "RoboFlax2", + playerUuid = PLAYER_UUID, + ) assertEquals( activity, @@ -122,5 +127,7 @@ class FriendControlWireTest { 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") } } 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 index e68d19668..d546be367 100644 --- 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 @@ -36,6 +36,16 @@ public abstract class ServerLoginPacketListenerMixin { 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; } 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 index 8887ee8da..2ab42f875 100644 --- 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 @@ -9,10 +9,12 @@ 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.MinecraftStatusProbe +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 @@ -182,7 +184,7 @@ class ConnectShare12111Client : ClientModInitializer { installationReference.get() ?: return@register val server = minecraft.singleplayerServer - val worldAvailable = minecraft.hasSingleplayerServer() + val worldAvailable = server != null && minecraft.connection != null worldAvailableSnapshot.set(worldAvailable) playerCountSnapshot.set( server?.playerList?.playerCount ?: 0, @@ -195,14 +197,13 @@ class ConnectShare12111Client : ClientModInitializer { ?.takeIf { !worldAvailable } joinTargetSnapshot.set(externalServer?.ip) activitySnapshot.set( - if (externalServer != null) { - FriendActivity( - FriendActivityKind.PLAYING_SERVER, - externalServer.name, - ) - } else { - FriendActivity(FriendActivityKind.ONLINE) - }, + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + ), ) ConnectShareClient.integratedWorldChanged( worldAvailable, @@ -264,7 +265,7 @@ class ConnectShare12111Client : ClientModInitializer { } private companion object { - const val PRESENCE_REFRESH_MILLIS = 30_000L + const val PRESENCE_REFRESH_MILLIS = 10_000L val LOGGER: Logger = Logger.getLogger("Connect") } @@ -298,6 +299,7 @@ class ConnectShare12111Client : ClientModInitializer { 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/FriendCardNetworking.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/FriendCardNetworking.kt index ddd793d95..294da7970 100644 --- 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 @@ -41,6 +41,7 @@ object FriendCardNetworking { displayName = player.gameProfile.name(), authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, ) } } 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 index 8974cff9e..50cb67665 100644 --- 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 @@ -7,6 +7,7 @@ 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.DirectOnlineAuthenticationRequired +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.tunnel.p2p.DirectP2pRoute @@ -16,6 +17,8 @@ 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 @@ -50,6 +53,19 @@ object Minecraft12111LoginBridge { 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, 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 index 5f8136715..87f039212 100644 --- 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 @@ -3,6 +3,7 @@ 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.FriendJoinApproval import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.FriendActivityMonitor import com.minekube.connect.share.fabric.GuestJoinTarget @@ -686,20 +687,40 @@ class ShareJoinScreen( } ConnectShareClient.friendRequestClient().requestJoin( target, - FriendJoinRequest(UUID.randomUUID()), + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), ).fold( ifLeft = { failure -> joining = false safeMessage = failure.safeMessage rebuildWidgets() }, - ifRight = { address -> - connect(GuestJoinTarget.Connect(address)) + ifRight = { approval -> + when (approval) { + is FriendJoinApproval.ExternalServer -> + connect(GuestJoinTarget.Connect(approval.address)) + FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + } }, ) } } + private suspend fun joinApprovedWorld(peerId: String) { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -943,6 +964,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> Component.translatable( "connect_share.friends.playing_server", 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 index d52af6fda..c5885433f 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Verwalten", "connect_share.friends.request_join": "Anfragen", "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", @@ -95,7 +96,7 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index 48b8239f4..3daee1fb2 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Manage", "connect_share.friends.request_join": "Request", "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 friend", @@ -95,7 +96,7 @@ "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 online. Open Friends to join.", + "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", 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 index ff9741385..faaa8f7b7 100644 --- 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 @@ -38,6 +38,16 @@ private void startClientVerification(GameProfile profile) { 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; } 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 index 181a07a99..c45b4a9aa 100644 --- 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 @@ -9,10 +9,12 @@ 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.MinecraftStatusProbe +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 @@ -182,7 +184,7 @@ class ConnectShare262Client : ClientModInitializer { installationReference.get() ?: return@register val server = minecraft.singleplayerServer - val worldAvailable = minecraft.hasSingleplayerServer() + val worldAvailable = server != null && minecraft.connection != null worldAvailableSnapshot.set(worldAvailable) playerCountSnapshot.set( server?.playerList?.playerCount ?: 0, @@ -195,14 +197,13 @@ class ConnectShare262Client : ClientModInitializer { ?.takeIf { !worldAvailable } joinTargetSnapshot.set(externalServer?.ip) activitySnapshot.set( - if (externalServer != null) { - FriendActivity( - FriendActivityKind.PLAYING_SERVER, - externalServer.name, - ) - } else { - FriendActivity(FriendActivityKind.ONLINE) - }, + FriendActivityResolver.resolve( + worldAvailable = worldAvailable, + worldSharingActive = installation.viewModel.state.value + .shareState is ShareState.Sharing, + worldName = worldNameSnapshot.get(), + externalServerName = externalServer?.name, + ), ) ConnectShareClient.integratedWorldChanged( worldAvailable, @@ -264,7 +265,7 @@ class ConnectShare262Client : ClientModInitializer { } private companion object { - const val PRESENCE_REFRESH_MILLIS = 30_000L + const val PRESENCE_REFRESH_MILLIS = 10_000L val LOGGER: Logger = Logger.getLogger("Connect") } @@ -298,6 +299,7 @@ class ConnectShare262Client : ClientModInitializer { 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/FriendCardNetworking.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/FriendCardNetworking.kt index fe7ee0db0..ad5464aa4 100644 --- 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 @@ -41,6 +41,7 @@ object FriendCardNetworking { displayName = player.gameProfile.name(), authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, ) } } 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 index a68f2b899..8820664b4 100644 --- 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 @@ -7,6 +7,7 @@ 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.DirectOnlineAuthenticationRequired +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.tunnel.p2p.DirectP2pRoute @@ -16,6 +17,8 @@ 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 @@ -50,6 +53,19 @@ object Minecraft262LoginBridge { 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, 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 index 0bd775d1b..0321f2f33 100644 --- 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 @@ -3,6 +3,7 @@ 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.FriendJoinApproval import com.minekube.connect.share.fabric.FriendPresenceMonitor import com.minekube.connect.share.fabric.FriendActivityMonitor import com.minekube.connect.share.fabric.GuestJoinTarget @@ -686,20 +687,40 @@ class ShareJoinScreen( } ConnectShareClient.friendRequestClient().requestJoin( target, - FriendJoinRequest(UUID.randomUUID()), + FriendJoinRequest( + requestId = UUID.randomUUID(), + playerName = minecraft.user.name, + playerUuid = minecraft.user.profileId, + ), ).fold( ifLeft = { failure -> joining = false safeMessage = failure.safeMessage rebuildWidgets() }, - ifRight = { address -> - connect(GuestJoinTarget.Connect(address)) + ifRight = { approval -> + when (approval) { + is FriendJoinApproval.ExternalServer -> + connect(GuestJoinTarget.Connect(approval.address)) + FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + } }, ) } } + private suspend fun joinApprovedWorld(peerId: String) { + friends.join( + peerId = peerId, + browser = browser, + authMode = authMode(), + ownConnectAddress = ConnectShareClient.connectPublicAddress(), + ).fold( + ifLeft = ::joinFailed, + ifRight = ::connect, + ) + } + private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -942,6 +963,13 @@ class ShareJoinScreen( } private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> Component.translatable( "connect_share.friends.playing_server", 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 index d52af6fda..c5885433f 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Verwalten", "connect_share.friends.request_join": "Anfragen", "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", @@ -95,7 +96,7 @@ "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 ist online. Öffne Freunde zum Beitreten.", + "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", 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 index 48b8239f4..3daee1fb2 100644 --- 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 @@ -66,6 +66,7 @@ "connect_share.friends.manage": "Manage", "connect_share.friends.request_join": "Request", "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 friend", @@ -95,7 +96,7 @@ "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 online. Open Friends to join.", + "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", 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 index 28284409c..722cc2245 100644 --- 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 @@ -10,7 +10,20 @@ data object DirectOnlineAuthenticationRequired { "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, 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..c1707ba5f --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt @@ -0,0 +1,23 @@ +package com.minekube.connect.share.fabric + +import com.minekube.connect.share.friend.FriendActivity +import com.minekube.connect.share.friend.FriendActivityKind + +object FriendActivityResolver { + fun resolve( + worldAvailable: Boolean, + worldSharingActive: Boolean, + worldName: String?, + externalServerName: String?, + ): FriendActivity = when { + externalServerName != null -> FriendActivity( + FriendActivityKind.PLAYING_SERVER, + externalServerName, + ) + worldAvailable && worldSharingActive -> FriendActivity( + FriendActivityKind.HOSTING_WORLD, + worldName?.takeIf(String::isNotBlank) ?: "Minecraft world", + ) + else -> FriendActivity(FriendActivityKind.ONLINE) + } +} 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 index 220414f43..01ecf78d8 100644 --- 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 @@ -30,13 +30,14 @@ class FriendCardReceiver( invitation: String, displayName: String, authenticatedMinecraftUuid: UUID?, + allowAutomaticJoin: Boolean = false, now: Instant = Instant.now(), ): Either = - store.acceptAndAllowJoin( - invitation, - displayName, - now, - ).flatMap { friend -> + (if (allowAutomaticJoin) { + store.acceptAndAllowJoin(invitation, displayName, now) + } else { + store.accept(invitation, displayName, now) + }).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> store.linkMinecraftIdentity( friend.peerId, 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 index 5f00a80f1..d85457ac6 100644 --- 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 @@ -49,6 +49,12 @@ sealed interface FriendRequestFailure { } } +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), @@ -112,6 +118,7 @@ class FriendRequestClient( is FriendControlResponse.Activity, is FriendControlResponse.JoinAccepted, + FriendControlResponse.SharedWorldJoinAccepted, -> outcome = FriendRequestFailure.InvalidResponse.left() } @@ -162,6 +169,7 @@ class FriendRequestClient( is FriendControlResponse.Accepted, is FriendControlResponse.Activity, is FriendControlResponse.JoinAccepted, + FriendControlResponse.SharedWorldJoinAccepted, -> return@withContext FriendRequestFailure.InvalidResponse.left() } } @@ -199,13 +207,16 @@ class FriendRequestClient( suspend fun requestJoin( target: GuestJoinTarget.Direct, request: FriendJoinRequest, - ): Either = + ): Either = exchangeControl( target, FriendControlWire.encodeJoinRequest(request), ).flatMap { response -> when (response) { - is FriendControlResponse.JoinAccepted -> response.address.right() + 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() 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 index 51ab6a691..c30378f18 100644 --- 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 @@ -115,21 +115,33 @@ class FriendRequestServer( ): CompletionStage = launchResponse { val friend = authenticatedFriend(context) ?: return@launchResponse FriendControlResponse.Invalid - if (activity().kind != FriendActivityKind.PLAYING_SERVER) { + 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 = friend.displayName, - uuid = friend.shareId, + 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 -> joinTarget() - ?.takeIf(String::isNotBlank) - ?.let(FriendControlResponse::JoinAccepted) - ?: FriendControlResponse.Invalid + 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, 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 index 6aec8793c..0f97f2ad3 100644 --- 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 @@ -49,6 +49,14 @@ class SocialEventTracker { 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, 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 index 2a1e0be88..360eb282b 100644 --- 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 @@ -294,9 +294,12 @@ class FriendsViewModel( activityKind = activity?.kind, activityDescription = activity?.description, canRequestJoin = - activity?.kind == FriendActivityKind.PLAYING_SERVER, + activity?.kind == FriendActivityKind.PLAYING_SERVER || + activity?.kind == FriendActivityKind.HOSTING_WORLD && + remote != null, canJoinNow = remote != null && - activity?.kind != FriendActivityKind.PLAYING_SERVER, + activity?.kind != FriendActivityKind.PLAYING_SERVER && + activity?.kind != FriendActivityKind.HOSTING_WORLD, ) } 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 index 809a8046b..895f523fd 100644 --- 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 @@ -3,6 +3,7 @@ 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 { @@ -25,4 +26,24 @@ class FabricDirectAuthenticationPolicyTest { ), ) } + + @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/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/FriendCardIssuerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendCardIssuerTest.kt index 668f7c1ff..92eccd392 100644 --- 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 @@ -77,6 +77,7 @@ class FriendCardIssuerTest { invitation = card, displayName = "Robin", authenticatedMinecraftUuid = minecraftUuid, + allowAutomaticJoin = true, now = NOW, ) 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 index 07bfd97ff..e5c6bb5fd 100644 --- 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 @@ -4,6 +4,10 @@ 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 @@ -59,6 +63,12 @@ class FriendPairingDirectE2ETest { connectedCount = { 0 }, maxGuests = { 8 }, ) + val hostActivity = AtomicReference( + FriendActivity( + FriendActivityKind.PLAYING_SERVER, + "Hypixel", + ), + ) val hostServer = FriendRequestServer( scope = this, admission = admission, @@ -69,12 +79,7 @@ class FriendPairingDirectE2ETest { friendStore = hostStore, now = { now }, ioDispatcher = Dispatchers.IO, - activity = { - FriendActivity( - FriendActivityKind.PLAYING_SERVER, - "Hypixel", - ) - }, + activity = hostActivity::get, joinTarget = { "mc.hypixel.net" }, ) @@ -195,7 +200,11 @@ class FriendPairingDirectE2ETest { val requestedJoin = async { requestClient.requestJoin( joinTarget, - FriendJoinRequest(java.util.UUID.randomUUID()), + FriendJoinRequest( + java.util.UUID.randomUUID(), + "bob", + java.util.UUID.randomUUID(), + ), ) } val joinAdmission = withTimeout(5.seconds) { @@ -206,10 +215,58 @@ class FriendPairingDirectE2ETest { } admission.answer(joinAdmission.requestId, allow = true) assertEquals( - "mc.hypixel.net", + 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() 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 index 2ff84f62c..facd44fc8 100644 --- 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 @@ -181,7 +181,11 @@ class FriendRequestClientTest { @Test fun `join request returns address only after remote acceptance`() = runBlocking { - val request = FriendJoinRequest(UUID.randomUUID()) + val request = FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ) val server = responseServer( FriendControlWire.encodeJoinRequest(request), FriendControlResponse.JoinAccepted("mc.hypixel.net"), @@ -190,7 +194,31 @@ class FriendRequestClientTest { val result = FriendRequestClient(ioDispatcher = Dispatchers.IO) .requestJoin(directTarget(server), request) - assertEquals("mc.hypixel.net", assertIs>(result).value) + 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( @@ -257,5 +285,7 @@ class FriendRequestClientTest { 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 index 2c7c72df2..8d71d47dc 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -74,7 +75,7 @@ class FriendRequestServerTest { ShareInviteCodec.decode(accepted.invitation, NOW).isRight(), ) assertEquals(senderPeerId, hostStore.all().single().peerId) - assertTrue(hostStore.all().single().permissions.canJoinAutomatically) + assertFalse(hostStore.all().single().permissions.canJoinAutomatically) } @Test @@ -149,7 +150,7 @@ class FriendRequestServerTest { assertTrue(admission.pending.value.isEmpty()) val confirmed = hostStore.all().single() assertEquals(senderPeerId, confirmed.peerId) - assertTrue(confirmed.permissions.canJoinAutomatically) + assertFalse(confirmed.permissions.canJoinAutomatically) assertTrue(hostStore.outgoingRequests().isEmpty()) assertEquals(1, relationshipsChanged) } @@ -262,13 +263,18 @@ class FriendRequestServerTest { ) val response = server.handleJoin( FriendControlContext(Ingress.DIRECT_LAN, senderPeerId), - FriendJoinRequest(UUID.randomUUID()), + FriendJoinRequest( + UUID.randomUUID(), + "RoboFlax2", + PLAYER_UUID, + ), ).toCompletableFuture() runCurrent() val pending = admission.pending.value.single() assertEquals(AdmissionPurpose.JOIN, pending.purpose) - assertEquals("bob", pending.identity.name) + assertEquals("RoboFlax2", pending.identity.name) + assertEquals(PLAYER_UUID, pending.identity.uuid) admission.answer(pending.requestId, allow = true) runCurrent() @@ -278,6 +284,49 @@ class FriendRequestServerTest { ) } + @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), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, @@ -302,5 +351,7 @@ class FriendRequestServerTest { 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/PrismFriendJoinE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt new file mode 100644 index 000000000..70bab6180 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt @@ -0,0 +1,125 @@ +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 java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assumptions.assumeTrue + +/** + * 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 { + @Test + fun `saved friend requests and joins a live singleplayer world`() = + runBlocking { + val dataValue = System.getenv("LIVE_DATA") + val portValue = System.getenv("LIVE_PORT_FILE") + val hostLogValue = System.getenv("LIVE_HOST_LOG") + assumeTrue( + dataValue != null && portValue != null && hostLogValue != null, + "LIVE_DATA, LIVE_PORT_FILE, and LIVE_HOST_LOG enable this E2E", + ) + val dataDirectory = Path.of(checkNotNull(dataValue)) + val portFile = Path.of(checkNotNull(portValue)) + val hostLog = Path.of(checkNotNull(hostLogValue)) + val playerName = System.getenv("LIVE_PLAYER_NAME") ?: "bob" + val joinedLine = "] $playerName joined the game" + val joinsBefore = Files.readString(hostLog) + .lineSequence() + .count { joinedLine in it } + val friend = FriendStore(dataDirectory).all().single() + 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()!! + assertEquals( + FriendActivityKind.HOSTING_WORLD, + activityTarget.use { + client.activity( + it, + com.minekube.connect.share.friend + .FriendActivityRequest(UUID.randomUUID()), + ).getOrNull()?.kind + }, + ) + + // Status and gameplay require different one-shot proxies. + assertTrue( + browser.probeLan( + friend, + DirectP2pAuthMode.OFFLINE, + MinecraftStatusProbe(), + ) != null, + ) + 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() + }, + ) + val gameplay = assertIs( + browser.join( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull(), + ) + gameplay.use { + Files.writeString( + portFile, + gameplay.localAddress.port.toString(), + ) + withTimeout(180_000) { + while (Files.readString(hostLog) + .lineSequence() + .count { joinedLine in it } <= joinsBefore + ) { + delay(100) + } + } + } + } finally { + browser.close() + } + } +} 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 index e2e424cd0..86dcbc7c7 100644 --- 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 @@ -46,6 +46,29 @@ class SocialEventTrackerTest { ) } + @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", 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 index 9af99d374..9571308ed 100644 --- 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 @@ -313,6 +313,39 @@ class FriendsViewModelTest { assertFalse(friend.canJoinNow) } + @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 `joining a saved friend does not expose its stored capability`() = runTest { val link = signedLink() From e831b8a997b69fbd90b7dc5fc740b8c517473ee6 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 17:40:02 +0200 Subject: [PATCH 133/188] docs(share): design Prism E2E skill --- ...-07-31-connect-share-prism-skill-design.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md 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..8451a5f22 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-connect-share-prism-skill-design.md @@ -0,0 +1,59 @@ +# 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, 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 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. From 7d8cb3a5b5e0db84f6eff69de3d0034d816464cd Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 31 Jul 2026 17:55:14 +0200 Subject: [PATCH 134/188] docs(share): add Prism E2E agent skill --- .../skills/connect-share-prism-e2e/SKILL.md | 139 ++++++++++++++++++ .../agents/openai.yaml | 4 + 2 files changed, 143 insertions(+) create mode 100644 .agents/skills/connect-share-prism-e2e/SKILL.md create mode 100644 .agents/skills/connect-share-prism-e2e/agents/openai.yaml 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..e94796bec --- /dev/null +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -0,0 +1,139 @@ +--- +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, status, approval, and Minecraft login as +separate gates; success at an earlier gate never proves a later one. + +## 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. + +Confirm each fresh `latest.log` contains both Fabric Loader startup and a +`connect-share` mod entry. Fabric Language Kotlin is packaged as a declared 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 `Published LAN +server`. 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`. + +## 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_PORT_FILE= \ +LIVE_HOST_LOG= \ +LIVE_PLAYER_NAME= \ +./gradlew :share:fabric-common:test \ + --tests '*PrismFriendJoinE2ETest*' --no-parallel +``` + +The test must remain running while the external guest uses the port written to +`LIVE_PORT_FILE`. It proves, in order: + +1. mDNS discovers the saved confirmed friend's peer identity. +2. Authenticated friend control reports `HOSTING_WORLD`. +3. A dedicated direct proxy answers a real Minecraft status probe. +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. + +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. + +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. + +## 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. +- **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. 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 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. + +## 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." From 4118768dff4d59941901bef4dcc2f9531171209b Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Fri, 31 Jul 2026 23:46:45 +0200 Subject: [PATCH 135/188] no-mistakes(review): Fix Connect Share review findings --- .../v1_21_11/ConnectShare12111Client.kt | 3 - .../v26_2/mixin/IntegratedServerMixin.java | 2 +- .../fabric/v26_2/ConnectShare262Client.kt | 3 - .../fabric/v26_2/Fabric262ArtifactTest.kt | 22 ++++++ .../share/fabric/ConnectControlPlane.kt | 8 ++ .../share/fabric/ConnectShareClient.kt | 4 +- .../share/fabric/FabricDirectPeerRuntime.kt | 15 ++-- .../share/fabric/FabricDirectShareIngress.kt | 6 +- .../share/fabric/FabricShareBootstrap.kt | 58 ++++++++------ .../connect/share/fabric/FriendCardIssuer.kt | 6 +- .../share/fabric/FriendPresenceMonitor.kt | 33 +------- .../share/fabric/PersistentConnectIngress.kt | 15 ++++ .../share/fabric/PersistentDirectIngress.kt | 75 ++++++++++--------- .../connect/share/fabric/ui/ShareViewModel.kt | 66 +++++++++++++--- .../share/fabric/FriendPresenceMonitorTest.kt | 62 ++++++--------- .../fabric/PersistentConnectIngressTest.kt | 20 +++++ .../fabric/PersistentDirectIngressTest.kt | 24 ++++-- .../share/fabric/ui/ShareViewModelTest.kt | 64 +++++++++++++++- 18 files changed, 315 insertions(+), 171 deletions(-) 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 index 2ab42f875..cf23b2c50 100644 --- 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 @@ -77,7 +77,6 @@ class ConnectShare12111Client : ClientModInitializer { val statusProbe = MinecraftStatusProbe() val remotePresence = FriendPresenceMonitor( store = friendStore, - probe = statusProbe, directProbe = { friend -> browserReference.get()?.probeLan( friend = friend, @@ -85,8 +84,6 @@ class ConnectShare12111Client : ClientModInitializer { probe = statusProbe, ) }, - ownConnectAddress = - ConnectShareClient::connectPublicAddress, ) scope.launch { while (isActive) { 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 index 042dd91d8..6d69d3727 100644 --- 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 @@ -10,7 +10,7 @@ @Mixin(IntegratedServer.class) public abstract class IntegratedServerMixin { @Redirect( - method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;I)Z", + method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;Lnet/minecraft/world/level/GameType;ZI)Z", at = @At( value = "INVOKE", target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) 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 index c45b4a9aa..3de8ee6ce 100644 --- 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 @@ -77,7 +77,6 @@ class ConnectShare262Client : ClientModInitializer { val statusProbe = MinecraftStatusProbe() val remotePresence = FriendPresenceMonitor( store = friendStore, - probe = statusProbe, directProbe = { friend -> browserReference.get()?.probeLan( friend = friend, @@ -85,8 +84,6 @@ class ConnectShare262Client : ClientModInitializer { probe = statusProbe, ) }, - ownConnectAddress = - ConnectShareClient::connectPublicAddress, ) scope.launch { while (isActive) { 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 index 9660c135a..f9eabfc53 100644 --- 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 @@ -188,6 +188,28 @@ class Fabric262ArtifactTest { } } + @Test + fun `mixin redirects the four 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;" + + "Lnet/minecraft/world/level/GameType;ZI)Z" in bytecode, + ) + assertFalse( + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + bytecode, + ) + } + } + @Test fun `packaged loader reads libp2p only from child payload`() { URLClassLoader( 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 index a92886171..8bbc31119 100644 --- 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 @@ -53,4 +53,12 @@ class ConnectControlPlane( 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 index 677b672b9..30597c0b4 100644 --- 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 @@ -31,7 +31,7 @@ data class ConnectShareInstallation( val browser: FabricShareBrowser, val friendActivity: FriendActivityMonitor, val gateway: ShareConnectionGateway, - val ownConnectAddress: String, + val ownConnectAddress: () -> String, val screens: ConnectShareScreenFactory, val guestScreens: ConnectShareGuestScreenFactory, ) @@ -115,7 +115,7 @@ object ConnectShareClient { @JvmStatic fun connectPublicAddress(): String? = - installation?.ownConnectAddress + installation?.ownConnectAddress?.invoke() @JvmStatic fun armFriendCardExchange(peerId: String) { 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 index 5b547f486..278d256a5 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -17,13 +18,16 @@ internal class FabricDirectPeerRuntime private constructor( ) { constructor( dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( - node = CoreFabricDirectPeerNode( - DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + browser = FabricShareBrowser(dataDirectory), + ingress = FabricDirectShareIngress( + dataDirectory = dataDirectory, + displayName = displayName, + accessIdentityStore = accessIdentityStore, ), - dataDirectory = dataDirectory, - displayName = displayName, ) private constructor( @@ -49,9 +53,6 @@ internal class FabricDirectPeerRuntime private constructor( dataDirectory = dataDirectory, displayName = displayName, ) - - private const val IDENTITY_FILE_NAME = - "share-libp2p-identity.key" } } 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 index 591f47a41..7acf402ef 100644 --- 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 @@ -32,6 +32,8 @@ class FabricDirectShareIngress private constructor( ) : DirectShareIngress { constructor( dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( nodeFactory = { @@ -40,9 +42,7 @@ class FabricDirectShareIngress private constructor( ) }, now = Instant::now, - accessIdentity = ShareAccessIdentityStore( - dataDirectory, - )::currentOrCreate, + accessIdentity = accessIdentityStore::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, ) 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 index 155b5b1c7..01dd48408 100644 --- 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 @@ -16,6 +16,7 @@ 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.ShareAccessIdentityStore import com.minekube.connect.share.friend.SharePreferences import com.minekube.connect.share.friend.SharePreferencesStore import com.minekube.connect.share.identity.EndpointIdentityStore @@ -108,12 +109,15 @@ object FabricShareBootstrap { timeout = 10.seconds, ) val endpointIdentity = identityStore.currentOrCreate() - val ownConnectAddress = - "${endpointIdentity.endpoint}.play.minekube.net" + val ownConnectAddress = AtomicReference( + "${endpointIdentity.endpoint}.play.minekube.net", + ) + val accessIdentityStore = ShareAccessIdentityStore(dataDirectory) val friendCardIssuer = FriendCardIssuer( dataDirectory = dataDirectory, displayName = playerDisplayName, - connectAddress = { ownConnectAddress }, + connectAddress = { ownConnectAddress.get() }, + accessIdentityStore = accessIdentityStore, ) val friendCardReceiver = FriendCardReceiver(friendStore) val friendRequestServer = FriendRequestServer( @@ -131,6 +135,7 @@ object FabricShareBootstrap { val directPeer = FabricDirectPeerRuntime( dataDirectory = dataDirectory, displayName = worldDisplayName, + accessIdentityStore = accessIdentityStore, ) val activeBrowser = directPeer.browser browser = activeBrowser @@ -169,6 +174,25 @@ object FabricShareBootstrap { directIngress = directIngress, failureReporter = logger::warn, ) + val controlPlane = ConnectControlPlane( + scope = scope, + ingress = ingress, + identity = identityStore::currentOrCreate, + target = gateway.serverSocketAddress, + failureReporter = logger::warn, + ).also(ConnectControlPlane::start) + val directControlPlane = DirectControlPlane( + scope = scope, + ingress = directIngress, + options = ShareOptions( + gameMode = ShareGameMode.SURVIVAL, + allowCheats = false, + allowInternetDirect = false, + ), + target = gateway.directAddress, + connectAddress = { ownConnectAddress.get() }, + failureReporter = logger::warn, + ).also(DirectControlPlane::start) val viewModel = ShareViewModel( scope = scope, shareState = coordinator.state, @@ -185,6 +209,13 @@ object FabricShareBootstrap { store = identityStore, validator = validator, ), + onIdentityChanged = { + ownConnectAddress.set( + "${identityStore.currentOrCreate().endpoint}" + + ".play.minekube.net", + ) + controlPlane.restart() + }, startShare = coordinator::start, stopShare = coordinator::stop, answerAdmission = admission::answer, @@ -262,25 +293,6 @@ object FabricShareBootstrap { receiver = friendCardReceiver, requestClient = friendRequestClient, ) - val controlPlane = ConnectControlPlane( - scope = scope, - ingress = ingress, - identity = { endpointIdentity }, - target = gateway.serverSocketAddress, - failureReporter = logger::warn, - ).also(ConnectControlPlane::start) - val directControlPlane = DirectControlPlane( - scope = scope, - ingress = directIngress, - options = ShareOptions( - gameMode = ShareGameMode.SURVIVAL, - allowCheats = false, - allowInternetDirect = false, - ), - target = gateway.directAddress, - connectAddress = { ownConnectAddress }, - failureReporter = logger::warn, - ).also(DirectControlPlane::start) return ConnectShareInstallation( viewModel = viewModel, friendsViewModel = friendsViewModel, @@ -295,7 +307,7 @@ object FabricShareBootstrap { browser = activeBrowser, friendActivity = activityMonitor, gateway = gateway, - ownConnectAddress = ownConnectAddress, + ownConnectAddress = ownConnectAddress::get, screens = screens, guestScreens = guestScreens, ) 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 index 01ecf78d8..673ca597c 100644 --- 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 @@ -50,6 +50,8 @@ class FriendCardReceiver( class FriendCardIssuer( private val dataDirectory: Path, private val displayName: () -> String? = { null }, + private val accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), private val connectAddress: suspend () -> String?, ) { suspend fun issue( @@ -63,9 +65,7 @@ class FriendCardIssuer( FriendCardIssueFailure } Either.catch { - val access = ShareAccessIdentityStore( - dataDirectory, - ).currentOrCreate() + val access = accessIdentityStore.currentOrCreate() DirectP2pNode( dataDirectory.resolve(IDENTITY_FILE_NAME), ).use { node -> 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 index c3d7a6cba..ccc3ee8f3 100644 --- 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 @@ -41,22 +41,16 @@ class FriendOnlineTracker { class FriendPresenceMonitor private constructor( private val friends: () -> List, - private val probe: FriendStatusProbe, private val directProbe: suspend (SavedFriend) -> ServerPresence?, - private val ownConnectAddress: () -> String?, private val ioDispatcher: CoroutineDispatcher, ) { constructor( store: FriendStore, - probe: FriendStatusProbe = MinecraftStatusProbe(), directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, - ownConnectAddress: () -> String? = { null }, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : this( friends = store::all, - probe = probe, directProbe = directProbe, - ownConnectAddress = ownConnectAddress, ioDispatcher = ioDispatcher, ) @@ -70,7 +64,6 @@ class FriendPresenceMonitor private constructor( val saved = runCatching(friends) .getOrDefault(emptyList()) .take(MAX_PROBED_FRIENDS) - val ownAddress = runCatching(ownConnectAddress).getOrNull() val results = saved.parMap( context = ioDispatcher, concurrency = MAX_CONCURRENT_PROBES, @@ -82,30 +75,14 @@ class FriendPresenceMonitor private constructor( } catch (_: Exception) { null } - val connectPresence = if (directPresence == null) { - friend.connectAddress?.let { address -> - if (connectAddressesMatch(address, ownAddress)) { - null - } else { - probe.probe(address).getOrNull() - } - } - } else { - null - } - val presence = directPresence ?: connectPresence friend.peerId to RemoteFriendPresence( peerId = friend.peerId, displayName = friend.displayName, - online = presence != null, - description = presence?.description, + online = directPresence != null, + description = directPresence?.description, notifyWhenOnline = friend.permissions.notifyWhenOnline, - route = when { - directPresence != null -> ShareRoute.DIRECT_LAN - connectPresence != null -> ShareRoute.CONNECT - else -> null - }, + route = directPresence?.let { ShareRoute.DIRECT_LAN }, ) } mutableState.value = results.toMap() @@ -114,17 +91,13 @@ class FriendPresenceMonitor private constructor( companion object { internal fun testing( friends: () -> List, - probe: FriendStatusProbe, directProbe: suspend (SavedFriend) -> ServerPresence? = { null }, - ownConnectAddress: () -> String? = { null }, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) = FriendPresenceMonitor( friends, - probe, directProbe, - ownConnectAddress, ioDispatcher, ) 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 index 9fae32759..e7c93740c 100644 --- 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 @@ -122,6 +122,21 @@ class PersistentConnectIngress( } } + 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, 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 index 875c01e00..0a9e4a3ab 100644 --- 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 @@ -72,38 +72,7 @@ class PersistentDirectIngress( .borrow(target, connectAddress) .right() - else -> { - mutableState.value = PersistentDirectState.Starting - 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() - } - } + else -> startFresh(options, target, connectAddress) } } @@ -111,11 +80,14 @@ class PersistentDirectIngress( options: ShareOptions, target: SocketAddress, connectAddress: String?, - ): DirectShareHandle = startControl( - options, - target, - connectAddress, - ).fold( + ): 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) }, @@ -155,4 +127,33 @@ class PersistentDirectIngress( 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/ui/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt index 1bf9547da..52b58f751 100644 --- 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 @@ -16,6 +16,8 @@ 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 @@ -114,6 +116,8 @@ class ShareViewModel( 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 mutableState = MutableStateFlow( ShareUiState( @@ -141,7 +145,7 @@ class ShareViewModel( update { copy(pendingAdmissions = next) } } } - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { val identity = identityActions.current() update { @@ -187,7 +191,7 @@ class ShareViewModel( fun start() { if (!state.value.startEnabled) return - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { setShareWithFriendsEnabled(true) startCurrentWorld() @@ -196,7 +200,7 @@ class ShareViewModel( } fun stop() { - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { try { setShareWithFriendsEnabled(false) @@ -214,8 +218,10 @@ class ShareViewModel( ) { return } - runOperation { - startCurrentWorld() + kotlinx.coroutines.withContext(operationDispatcher) { + runOperation { + startCurrentWorld() + } } } @@ -228,6 +234,10 @@ class ShareViewModel( } fun setImportEndpoint(endpoint: String) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } update { if (!importDraft.endpointEditable) { this @@ -238,6 +248,10 @@ class ShareViewModel( } fun setImportToken(token: String) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } update { if (!importDraft.tokenEditable) { this @@ -248,12 +262,16 @@ class ShareViewModel( } fun importIdentity() { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } val draft = state.value.importDraft if (!draft.endpointEditable || !draft.tokenEditable) { update { copy(safeMessage = MANAGED_MESSAGE) } return } - scope.launch(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { applyIdentityResult( identityActions.import(draft.endpoint, draft.token), @@ -263,12 +281,16 @@ class ShareViewModel( } 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(start = CoroutineStart.UNDISPATCHED) { + scope.launch(context = operationDispatcher) { runOperation { applyIdentityResult( identityActions.importTokenFile(draft.endpoint, tokenFile), @@ -278,14 +300,18 @@ class ShareViewModel( } fun resetIdentity() { - scope.launch(start = CoroutineStart.UNDISPATCHED) { + if (!identityChangesAllowed()) { + rejectIdentityChange() + return + } + scope.launch(context = operationDispatcher) { runOperation { applyIdentityResult(identityActions.reset()) } } } - private fun applyIdentityResult( + private suspend fun applyIdentityResult( result: Either, ) { result.fold( @@ -293,6 +319,7 @@ class ShareViewModel( update { copy(safeMessage = failure.safeMessage) } }, ifRight = { identity -> + onIdentityChanged() update { copy( identity = identity, @@ -349,6 +376,25 @@ class ShareViewModel( 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( @@ -361,6 +407,8 @@ class ShareViewModel( "Connect credentials are managed by the environment" const val GENERIC_FAILURE_MESSAGE = "Could not update Connect Share" + const val IDENTITY_ACTIVE_MESSAGE = + "Stop sharing before changing Connect credentials" } } 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 index 413773ff8..2bb9d8ae8 100644 --- 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 @@ -1,6 +1,5 @@ package com.minekube.connect.share.fabric -import arrow.core.Either import com.minekube.connect.share.direct.ShareRoute import com.minekube.connect.share.friend.FriendPermissions import com.minekube.connect.share.friend.SavedFriend @@ -28,9 +27,6 @@ class FriendPresenceMonitorTest { loads++ emptyList() }, - probe = FriendStatusProbe { - error("no friends should be probed") - }, ioDispatcher = io, ) @@ -56,11 +52,11 @@ class FriendPresenceMonitorTest { ) val monitor = FriendPresenceMonitor.testing( friends = { listOf(online, offline) }, - probe = FriendStatusProbe { address -> - if (address.startsWith("online")) { - Either.Right(ServerPresence("Robin's World")) + directProbe = { friend -> + if (friend.peerId == online.peerId) { + ServerPresence("Robin's World") } else { - Either.Left(StatusProbeError.EndpointOffline) + null } }, ) @@ -78,21 +74,16 @@ class FriendPresenceMonitorTest { } @Test - fun `direct LAN status is preferred before Connect presence`() = runTest { + fun `direct LAN status is authenticated before being reported`() = runTest { val nearby = friend( peerId = "12D3KooWNearby", address = "nearby.play.minekube.net", ) - val connectProbes = mutableListOf() val monitor = FriendPresenceMonitor.testing( friends = { listOf(nearby) }, directProbe = { ServerPresence("Robin's LAN World") }, - probe = FriendStatusProbe { address -> - connectProbes += address - Either.Right(ServerPresence("Wrong Connect World")) - }, ) monitor.refresh() @@ -101,7 +92,22 @@ class FriendPresenceMonitorTest { assertTrue(presence.online) assertEquals(ShareRoute.DIRECT_LAN, presence.route) assertEquals("Robin's LAN World", presence.description) - assertTrue(connectProbes.isEmpty()) + } + + @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 @@ -118,9 +124,6 @@ class FriendPresenceMonitorTest { directProbe = { throw CancellationException("cancelled") }, - probe = FriendStatusProbe { - Either.Right(ServerPresence("must not run")) - }, ) assertFailsWith { @@ -161,29 +164,6 @@ class FriendPresenceMonitorTest { ) } - @Test - fun `refresh never probes this profiles own Connect endpoint as a friend`() = - runTest { - val copied = friend( - peerId = "12D3KooWCopiedEndpoint", - address = "mine.play.minekube.net", - ) - val probed = mutableListOf() - val monitor = FriendPresenceMonitor.testing( - friends = { listOf(copied) }, - ownConnectAddress = { "mine.play.minekube.net" }, - probe = FriendStatusProbe { address -> - probed += address - Either.Right(ServerPresence("Wrong self presence")) - }, - ) - - monitor.refresh() - - assertTrue(probed.isEmpty()) - assertFalse(monitor.state.value.getValue(copied.peerId).online) - } - private fun friend( peerId: String, address: String, 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 index f445610f0..8b489680b 100644 --- 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 @@ -93,6 +93,26 @@ class PersistentConnectIngressTest { 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 { 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 index 333f4bff8..8287a1251 100644 --- 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 @@ -19,7 +19,7 @@ import kotlinx.coroutines.runBlocking class PersistentDirectIngressTest { @Test - fun `title startup and world leases share one direct host until shutdown`() = + fun `world starts replace the title host and publish current invitations`() = runBlocking { val delegate = FakeIngress() val persistent = PersistentDirectIngress(delegate) @@ -46,21 +46,27 @@ class PersistentDirectIngressTest { CONNECT_ADDRESS, ) val secondWorld = persistent.start( - CONTROL_OPTIONS, + CONTROL_OPTIONS.copy(allowInternetDirect = true), TARGET, CONNECT_ADDRESS, ) firstWorld.close() secondWorld.close() - assertEquals(0, delegate.closes.get()) - assertEquals(INVITATION, firstWorld.invitation) + 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(1, delegate.closes.get()) + assertEquals(3, delegate.closes.get()) assertEquals(PersistentDirectState.Closed, persistent.state.value) } @@ -101,14 +107,14 @@ class PersistentDirectIngressTest { ).getOrNull()!! assertFailsWith { - persistent.start( + persistent.startControl( CONTROL_OPTIONS, InetSocketAddress(InetAddress.getLoopbackAddress(), 25_566), CONNECT_ADDRESS, ) } assertFailsWith { - persistent.start( + persistent.startControl( CONTROL_OPTIONS, TARGET, "other.play.minekube.net", @@ -123,6 +129,7 @@ class PersistentDirectIngressTest { ) : DirectShareIngress { val starts = AtomicInteger() val closes = AtomicInteger() + val startedOptions = mutableListOf() override suspend fun start( options: ShareOptions, @@ -130,11 +137,12 @@ class PersistentDirectIngressTest { connectAddress: String?, ): DirectShareHandle { val attempt = starts.incrementAndGet() + startedOptions += options if (attempt <= failuresBeforeSuccess) { error("simulated direct startup failure") } return DirectShareHandle( - invitation = INVITATION, + invitation = "$INVITATION-$attempt", lanAvailable = true, internetAvailable = false, close = { 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 index 39a747674..fad15fe49 100644 --- 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 @@ -11,8 +11,11 @@ import com.minekube.connect.share.identity.CredentialSource import com.minekube.connect.share.identity.CredentialValidationError 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 @@ -148,6 +151,61 @@ class ShareViewModelTest { 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 `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( + "Stop sharing before changing Connect credentials", + viewModel.state.value.safeMessage, + ) + } + @Test fun `enabled friend sharing resumes automatically in a new world`() = runTest { var starts = 0 @@ -179,6 +237,9 @@ class ShareViewModelTest { pending: MutableStateFlow> = MutableStateFlow(emptyList()), worldAvailable: Boolean = true, + scope: CoroutineScope = backgroundScope, + operationDispatcher: CoroutineDispatcher = + StandardTestDispatcher(testScheduler), identityActions: EndpointIdentityUiActions = FakeIdentityActions(localIdentity()), answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, @@ -195,12 +256,13 @@ class ShareViewModelTest { ) }, ) = ShareViewModel( - scope = backgroundScope, + scope = scope, shareState = shareState, pendingAdmissions = pending, initialWorldAvailable = worldAvailable, identityActions = identityActions, initialShareWithFriendsEnabled = initialShareWithFriends, + operationDispatcher = operationDispatcher, persistShareWithFriendsEnabled = persistShareWithFriends, startShare = startShare, stopShare = { Either.Right(Unit) }, From 87e9cb3bf0e191918bc2f4571ed95c9bc19dc265 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 00:14:34 +0200 Subject: [PATCH 136/188] no-mistakes(test): Fixed ShareViewModel scheduler setup; focused rerun passes --- .../com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fad15fe49..f25f82769 100644 --- 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 @@ -237,7 +237,7 @@ class ShareViewModelTest { pending: MutableStateFlow> = MutableStateFlow(emptyList()), worldAvailable: Boolean = true, - scope: CoroutineScope = backgroundScope, + scope: CoroutineScope = CoroutineScope(StandardTestDispatcher(testScheduler)), operationDispatcher: CoroutineDispatcher = StandardTestDispatcher(testScheduler), identityActions: EndpointIdentityUiActions = From 87ea27a79149f5a7463e37d8c9a7f127f47f7e22 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 00:40:35 +0200 Subject: [PATCH 137/188] no-mistakes(test): Fix Fabric 26.2 LAN redirect overload; focused tests pass --- .../share/fabric/v26_2/mixin/IntegratedServerMixin.java | 2 +- .../connect/share/fabric/v26_2/Fabric262ArtifactTest.kt | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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 index 6d69d3727..042dd91d8 100644 --- 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 @@ -10,7 +10,7 @@ @Mixin(IntegratedServer.class) public abstract class IntegratedServerMixin { @Redirect( - method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;Lnet/minecraft/world/level/GameType;ZI)Z", + method = "publishServer(Lnet/minecraft/server/MinecraftServer$MultiplayerScope;I)Z", at = @At( value = "INVOKE", target = "Lnet/minecraft/client/server/LanServerPinger;start()V")) 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 index f9eabfc53..44ec7811a 100644 --- 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 @@ -189,7 +189,7 @@ class Fabric262ArtifactTest { } @Test - fun `mixin redirects the four argument 262 publish overload`() { + 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/" + @@ -200,11 +200,12 @@ class Fabric262ArtifactTest { it.readBytes().toString(Charsets.ISO_8859_1) } assertTrue( - "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;" + - "Lnet/minecraft/world/level/GameType;ZI)Z" in bytecode, + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + bytecode, ) assertFalse( - "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;I)Z" in + "publishServer(Lnet/minecraft/server/MinecraftServer\$MultiplayerScope;" + + "Lnet/minecraft/world/level/GameType;ZI)Z" in bytecode, ) } From 5190ac53d7a9b278d7c4104fa212c0bf73ca8431 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 01:12:02 +0200 Subject: [PATCH 138/188] no-mistakes(document): Refreshed Connect Share docs and cleared whitespace lint --- README.md | 8 ++- docs/connect-share-testing.md | 17 +++--- .../2026-07-30-connect-share-direct-p2p.md | 12 ++-- .../2026-07-30-connect-share-singleplayer.md | 15 ++--- .../2026-07-30-connect-share-mod-design.md | 59 ++++++++++--------- ...-connect-share-pasted-lan-invite-design.md | 4 +- 6 files changed, 62 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index bb12087da..b13b6a709 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,15 @@ the LAN or internet. The current implementation provides: -- a native **Share with Connect** flow in the pause menu; -- a native **Join Connect Share** flow on the title screen; +- a native **Share with friends** flow in the pause menu; +- a native **Friends** flow on the title screen, including **Join Connect Share**; - one persistent endpoint identity reused across worlds and restarts; +- one authenticated libp2p friend identity, with presence and world details + visible only to confirmed friends; - import of an existing dashboard endpoint and token, including `token.json`; - `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; - a stable `*.play.minekube.net` address for unmodified Java clients; -- signed, temporary invitations for modded clients; +- signed friend links and temporary world invitations for modded clients; - automatic same-LAN discovery and direct libp2p transport; - optional internet-direct attempts only when host and guest both opt in; - exactly-once fallback to Connect, which is the only relay; diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 26f2cfd9b..79af80454 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -22,7 +22,7 @@ Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. ## Identity reuse and import -1. Start a singleplayer world and choose **Share with Connect**. +1. Start a singleplayer world and choose **Share with friends**. 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. @@ -33,8 +33,7 @@ Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. 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, including any - hostname or custom-domain configuration attached to it. +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. @@ -63,9 +62,9 @@ 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 with Connect**, and leave - **Allow direct internet connections** disabled. -2. On the guest title screen, choose **Join Connect Share**. +1. Start a host world, choose **Share with friends**, 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 @@ -75,9 +74,9 @@ route works. 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. -8. Start sharing again. Confirm the libp2p peer identity, share capability, and - invitation changed while the persistent Connect endpoint did not. + 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 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 index c3fc94ef6..985db3445 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-direct-p2p.md @@ -22,8 +22,10 @@ classloader boundary. - 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. -- Peer identities, capabilities, invitations, and approvals are ephemeral per - share. The Connect endpoint token remains the only persistent network secret. +- 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 @@ -38,7 +40,7 @@ classloader boundary. ## 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, ephemeral identities, + 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. @@ -62,9 +64,9 @@ classloader boundary. ## Task 4: Guest discovery, invitation join, and fallback - Add a shared browser/join service with bounded LAN and internet timeouts. -- Start discovery when the multiplayer/Join Share UI is open and remove it on +- Start discovery when the **Friends**/**Join Connect Share** UI is open and remove it on close. -- Add native Minecraft Join Share UI to both Fabric versions, including paste +- 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 diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 43acd590e..5f7ef4cfe 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -1158,27 +1158,28 @@ Listen for client disconnect/game shutdown/integrated-server replacement and cal - [ ] **Step 3: Implement exact screens** -The pause menu button is **Share with Connect** when idle and **Connect Share** when active. +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, and **Start Sharing**. +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**; -- **Endpoint identity** link. +- **Allow**, **Deny**, and **Stop sharing with friends**; +- **Advanced settings…** link. The identity screen contains: - endpoint name; - masked credential source; -- **Import existing endpoint**; +- **Import token.json…**; - endpoint field plus masked token field; - `token.json` chooser; - **Validate and save**; -- warned **Reset Connect identity**. +- warned **Reset endpoint identity…**. Never render or retain a successful token value. @@ -1313,7 +1314,7 @@ Archive each remapped mod JAR under a distinct artifact name. Do not add mod fil 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 hostname/custom-domain configuration. +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. 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 index 8799ee3f1..2c0491152 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -1,6 +1,6 @@ # Connect Share Mod Design -**Date:** 2026-07-30 +**Date:** 2026-07-30 **Status:** Approved for implementation **Epic:** [minekube/connect-java#83](https://github.com/minekube/connect-java/issues/83) @@ -23,7 +23,7 @@ logic is written in Kotlin and shared across both versions. ## Product Decisions -- The host starts sharing from a dedicated **Share with Connect** pause-menu +- 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 @@ -66,8 +66,8 @@ logic is written in Kotlin and shared across both versions. 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, public - hostname, and attached custom domains without creating a duplicate endpoint. +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. @@ -187,7 +187,7 @@ config/minekube-connect-share/config.json config/minekube-connect-share/token.json ``` -`config.json` stores the endpoint name and non-secret user settings. +`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 @@ -209,7 +209,7 @@ 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 Connect identity**. Resetting warns that it creates +explicitly choose **Reset endpoint identity…**. Resetting warns that it creates a new endpoint and invalidates the old local identity. The identity setup screen offers: @@ -250,8 +250,10 @@ 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. -Every share creates an ephemeral libp2p identity so separate shares cannot be -correlated by a stable peer ID. The direct service supports: +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; @@ -285,9 +287,9 @@ The signed payload contains: - share ID; - expiry; - persistent Connect hostname when Connect is available; -- ephemeral host peer ID; +- installation-scoped host peer ID; - direct candidates only when the host enabled internet P2P; -- an unguessable per-share capability; +- an unguessable persisted access capability; - the host peer signature over every preceding field. The capability authorizes requesting admission; it never bypasses host @@ -296,7 +298,7 @@ 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 Share screen. Pasting the URI into Minecraft's +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. @@ -345,14 +347,14 @@ indicator; it does not spam chat. ### Host -The pause menu contains **Share with Connect**. The setup screen shows: +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 direct internet connections**, off by default, with an IP-disclosure +- **Allow faster direct internet connections**, off by default, with an IP-disclosure warning; -- **Start Sharing**. +- **Share with friends**. While active, the screen shows: @@ -361,13 +363,14 @@ While active, the screen shows: - Connect, LAN direct, and internet direct status separately; - connected and approved players; - pending approval cards; -- **Stop Sharing**. +- **Stop sharing with friends**. Connect identity settings show the endpoint name, credential source (generated, imported, or environment), and a masked token status. They provide -**Import existing endpoint** and the separately warned **Reset Connect -identity** action. The token value is never displayed again after a successful -import. +**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. @@ -375,7 +378,8 @@ the screen does not stop sharing. ### Guest Vanilla guests add or directly connect to the host's Connect hostname. Modded -guests can use **Join Share** or paste a `minekube://share/` invitation. The +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. @@ -398,8 +402,9 @@ authenticated. 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. -- Approvals, share capabilities, and ephemeral peer identities die with the - share. The Connect endpoint name and token persist across shares. +- 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. @@ -408,7 +413,7 @@ authenticated. - 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 - ephemeral direct peer. + active direct peer. - Malformed, expired, unsupported-version, incorrectly signed, or capability-mismatched invitations are rejected before dialing. @@ -497,11 +502,11 @@ Before calling the feature complete: 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 and token are - reused while the old signed invitation is rejected. -12. Import a dashboard-created endpoint and token, then prove its hostname and - attached dashboard configuration are used without creating another - endpoint. +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. 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 index c23989fd9..8a393008a 100644 --- 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 @@ -1,7 +1,7 @@ # Connect Share Pasted LAN Invitation Design -**Date:** 2026-07-30 -**Status:** Approved for implementation +**Date:** 2026-07-30 +**Status:** Approved for implementation **Parent design:** `2026-07-30-connect-share-mod-design.md` ## Problem From ef3a78ad5296264d383b0833f61e7bd5eba9d4d5 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 01:39:05 +0200 Subject: [PATCH 139/188] no-mistakes: apply CI fixes --- .github/workflows/pullrequest.yml | 13 +++++-------- .github/workflows/release.yml | 2 +- build.gradle.kts | 2 +- settings.gradle.kts | 18 ++++++++++++------ 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index cb47ea70a..8bd02be56 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 543757b26..00e0c1d2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build - run: ./gradlew build + run: ./gradlew -Pskip-share=true build - name: Get version id: version diff --git a/build.gradle.kts b/build.gradle.kts index 9596b2df2..7ad94f382 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ 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 } diff --git a/settings.gradle.kts b/settings.gradle.kts index 04dfa74cc..87ef73136 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -96,9 +96,15 @@ include(":core") include(":bungee") include(":spigot") include(":velocity") -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-26-2") -project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") + +// 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-26-2") + project(":share:fabric-26-2").projectDir = file("share/fabric-26.2") +} From 2339c8c98bd8a48cc2134cfcaadc0b06031bf911 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 1 Aug 2026 21:07:14 +0200 Subject: [PATCH 140/188] feat(share): complete universal party experience --- .github/workflows/connect-share-release.yml | 195 +++ .github/workflows/pullrequest.yml | 55 + README.md | 18 +- build-logic/src/main/kotlin/Versions.kt | 2 + .../tunnel/p2p/DirectP2pNodeRuntime.java | 24 +- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 44 + docs/connect-share-testing.md | 48 +- docs/connect-share.md | 86 ++ settings.gradle.kts | 13 + share/AGENTS.md | 14 + share/common/build.gradle.kts | 5 + .../share/friend/CompatibilityProfile.kt | 177 +++ .../connect/share/friend/FriendControlWire.kt | 97 +- .../connect/share/friend/FriendStore.kt | 157 ++- .../share/friend/SharePreferencesStore.kt | 39 +- .../share/friend/CompatibilityProfileTest.kt | 88 ++ .../share/friend/FriendControlWireTest.kt | 13 + .../connect/share/friend/FriendStoreTest.kt | 45 + .../share/friend/SharePreferencesStoreTest.kt | 18 + share/fabric-1.20.1/build.gradle.kts | 162 +++ .../v1_20_1/MinecraftGameProfileFactory.java | 18 + .../v1_20_1/mixin/ConnectionAccessor.java | 12 + .../mixin/IntegratedServerAccessor.java | 19 + .../v1_20_1/mixin/IntegratedServerMixin.java | 24 + .../mixin/LanServerPingerAccessor.java | 12 + .../v1_20_1/mixin/PauseScreenMixin.java | 55 + .../ServerConnectionListenerAccessor.java | 13 + .../mixin/ServerConnectionListenerMixin.java | 57 + .../mixin/ServerLoginPacketListenerMixin.java | 99 ++ .../v1_20_1/mixin/TitleScreenMixin.java | 30 + .../fabric/v1_20_1/BlockedFriendsScreen.kt | 70 + .../v1_20_1/CompatibilityMismatchScreen.kt | 109 ++ .../v1_20_1/ConnectGameProfileMapper.kt | 50 + .../fabric/v1_20_1/ConnectShare12111Client.kt | 477 +++++++ .../fabric/v1_20_1/EndpointIdentityScreen.kt | 171 +++ .../v1_20_1/FabricConnectShare1201Client.kt | 68 + .../fabric/v1_20_1/FriendCardNetworking.kt | 84 ++ .../share/fabric/v1_20_1/FriendCardPayload.kt | 37 + .../fabric/v1_20_1/Minecraft12111Bridge.kt | 62 + .../v1_20_1/Minecraft12111LoginBridge.kt | 178 +++ .../fabric/v1_20_1/ObservableCheckbox.kt | 19 + .../share/fabric/v1_20_1/ShareJoinScreen.kt | 1192 +++++++++++++++++ .../fabric/v1_20_1/SharePrivacyScreen.kt | 108 ++ .../share/fabric/v1_20_1/ShareSetupScreen.kt | 151 +++ .../share/fabric/v1_20_1/ShareStatusScreen.kt | 210 +++ .../v1_20_1/VanillaMinecraft12111Transport.kt | 149 +++ .../assets/connect-share/lang/de_de.json | 149 +++ .../assets/connect-share/lang/en_us.json | 149 +++ .../connect-share-fabric-1.20.1.mixins.json | 22 + .../src/main/resources/fabric.mod.json | 26 + .../v1_20_1/CapturedServerTransportTest.kt | 58 + .../v1_20_1/ConnectGameProfileMapperTest.kt | 47 + .../fabric/v1_20_1/Fabric12111ArtifactTest.kt | 320 +++++ .../fabric/v1_20_1/FriendCardPayloadTest.kt | 41 + .../v1_20_1/Minecraft12111BridgeTest.kt | 125 ++ share/fabric-1.21.1/build.gradle.kts | 160 +++ .../v1_21_1/MinecraftGameProfileFactory.java | 23 + .../v1_21_1/mixin/ConnectionAccessor.java | 12 + .../mixin/IntegratedServerAccessor.java | 19 + .../v1_21_1/mixin/IntegratedServerMixin.java | 24 + .../mixin/LanServerPingerAccessor.java | 12 + .../v1_21_1/mixin/PauseScreenMixin.java | 55 + .../ServerConnectionListenerAccessor.java | 13 + .../mixin/ServerConnectionListenerMixin.java | 57 + .../mixin/ServerLoginPacketListenerMixin.java | 101 ++ .../v1_21_1/mixin/TitleScreenMixin.java | 30 + .../fabric/v1_21_1/BlockedFriendsScreen.kt | 70 + .../v1_21_1/CompatibilityMismatchScreen.kt | 109 ++ .../v1_21_1/ConnectGameProfileMapper.kt | 48 + .../fabric/v1_21_1/ConnectShare12111Client.kt | 482 +++++++ .../fabric/v1_21_1/EndpointIdentityScreen.kt | 171 +++ .../v1_21_1/FabricConnectShare1211Client.kt | 68 + .../fabric/v1_21_1/FriendCardNetworking.kt | 96 ++ .../share/fabric/v1_21_1/FriendCardPayload.kt | 55 + .../fabric/v1_21_1/Minecraft12111Bridge.kt | 62 + .../v1_21_1/Minecraft12111LoginBridge.kt | 179 +++ .../share/fabric/v1_21_1/ShareJoinScreen.kt | 1187 ++++++++++++++++ .../fabric/v1_21_1/SharePrivacyScreen.kt | 107 ++ .../share/fabric/v1_21_1/ShareSetupScreen.kt | 149 +++ .../share/fabric/v1_21_1/ShareStatusScreen.kt | 210 +++ .../v1_21_1/VanillaMinecraft12111Transport.kt | 149 +++ .../assets/connect-share/lang/de_de.json | 149 +++ .../assets/connect-share/lang/en_us.json | 149 +++ .../connect-share-fabric-1.21.1.mixins.json | 22 + .../src/main/resources/fabric.mod.json | 26 + .../v1_21_1/CapturedServerTransportTest.kt | 58 + .../v1_21_1/ConnectGameProfileMapperTest.kt | 47 + .../fabric/v1_21_1/Fabric12111ArtifactTest.kt | 288 ++++ .../fabric/v1_21_1/FriendCardPayloadTest.kt | 41 + .../v1_21_1/Minecraft12111BridgeTest.kt | 125 ++ share/fabric-1.21.11/build.gradle.kts | 14 + .../fabric/v1_21_11/BlockedFriendsScreen.kt | 70 + .../v1_21_11/CompatibilityMismatchScreen.kt | 109 ++ .../v1_21_11/ConnectShare12111Client.kt | 178 +++ .../share/fabric/v1_21_11/ShareJoinScreen.kt | 434 +++--- .../fabric/v1_21_11/SharePrivacyScreen.kt | 107 ++ .../share/fabric/v1_21_11/ShareSetupScreen.kt | 7 + .../fabric/v1_21_11/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 36 +- .../assets/connect-share/lang/en_us.json | 36 +- share/fabric-26.2/build.gradle.kts | 14 + .../fabric/v26_2/BlockedFriendsScreen.kt | 70 + .../v26_2/CompatibilityMismatchScreen.kt | 109 ++ .../fabric/v26_2/ConnectShare262Client.kt | 178 +++ .../share/fabric/v26_2/ShareJoinScreen.kt | 434 +++--- .../share/fabric/v26_2/SharePrivacyScreen.kt | 107 ++ .../share/fabric/v26_2/ShareSetupScreen.kt | 7 + .../share/fabric/v26_2/ShareStatusScreen.kt | 7 +- .../assets/connect-share/lang/de_de.json | 36 +- .../assets/connect-share/lang/en_us.json | 36 +- share/fabric-common/build.gradle.kts | 5 + .../share/fabric/ConnectShareClient.kt | 13 + .../share/fabric/FabricDirectPeerRuntime.kt | 15 +- .../share/fabric/FabricDirectShareIngress.kt | 27 +- .../share/fabric/FabricShareBootstrap.kt | 33 +- .../fabric/FollowNextSessionController.kt | 137 ++ .../share/fabric/FriendActivityResolver.kt | 12 +- .../share/fabric/FriendJoinOrchestrator.kt | 164 +++ .../share/fabric/FriendRequestServer.kt | 33 +- .../LoadedCompatibilityProfileFactory.kt | 91 ++ .../share/fabric/ShareJoinDiagnostics.kt | 65 + .../share/fabric/ui/FriendsViewModel.kt | 89 +- .../connect/share/fabric/ui/ListPage.kt | 35 + .../connect/share/fabric/ui/ShareViewModel.kt | 72 +- .../fabric/FabricDirectPeerRuntimeTest.kt | 49 +- .../fabric/FollowNextSessionControllerTest.kt | 128 ++ .../fabric/FriendJoinOrchestratorTest.kt | 134 ++ .../share/fabric/FriendRequestServerTest.kt | 104 ++ .../LoadedCompatibilityProfileFactoryTest.kt | 49 + .../share/fabric/PrismFriendJoinE2ETest.kt | 24 +- .../share/fabric/ShareJoinDiagnosticsTest.kt | 33 + .../share/fabric/ui/FriendsViewModelTest.kt | 63 + .../connect/share/fabric/ui/ListPageTest.kt | 41 + .../share/fabric/ui/ShareViewModelTest.kt | 49 + share/forge-1.20.1/build.gradle.kts | 219 +++ .../v1_20_1/ForgeConnectShare1201Client.kt | 78 ++ .../src/main/resources/META-INF/mods.toml | 36 + .../connect-share-forge-1.20.1.mixins.json | 23 + .../src/main/resources/pack.mcmeta | 6 + .../forge/v1_20_1/Forge1201ArtifactTest.kt | 55 + share/neoforge-1.21.1/build.gradle.kts | 181 +++ .../v1_21_1/NeoForgeConnectShare1211Client.kt | 76 ++ .../resources/META-INF/neoforge.mods.toml | 38 + .../src/main/resources/pack.mcmeta | 6 + .../v1_21_1/NeoForge1211ArtifactTest.kt | 42 + 145 files changed, 14052 insertions(+), 433 deletions(-) create mode 100644 .github/workflows/connect-share-release.yml create mode 100644 docs/connect-share.md create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/friend/CompatibilityProfile.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/friend/CompatibilityProfileTest.kt create mode 100644 share/fabric-1.20.1/build.gradle.kts create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/MinecraftGameProfileFactory.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ConnectionAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/PauseScreenMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapper.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FabricConnectShare1201Client.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111Bridge.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ObservableCheckbox.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt create mode 100644 share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-1.20.1/src/main/resources/connect-share-fabric-1.20.1.mixins.json create mode 100644 share/fabric-1.20.1/src/main/resources/fabric.mod.json create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/CapturedServerTransportTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Fabric12111ArtifactTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt create mode 100644 share/fabric-1.21.1/build.gradle.kts create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/MinecraftGameProfileFactory.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ConnectionAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/IntegratedServerMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/LanServerPingerAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/PauseScreenMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerAccessor.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerConnectionListenerMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/ServerLoginPacketListenerMixin.java create mode 100644 share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapper.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FabricConnectShare1211Client.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111Bridge.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt create mode 100644 share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json create mode 100644 share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json create mode 100644 share/fabric-1.21.1/src/main/resources/connect-share-fabric-1.21.1.mixins.json create mode 100644 share/fabric-1.21.1/src/main/resources/fabric.mod.json create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/CapturedServerTransportTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectGameProfileMapperTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Fabric12111ArtifactTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt create mode 100644 share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FollowNextSessionController.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestrator.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactory.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnostics.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ListPage.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendJoinOrchestratorTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ShareJoinDiagnosticsTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ListPageTest.kt create mode 100644 share/forge-1.20.1/build.gradle.kts create mode 100644 share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt create mode 100644 share/forge-1.20.1/src/main/resources/META-INF/mods.toml create mode 100644 share/forge-1.20.1/src/main/resources/connect-share-forge-1.20.1.mixins.json create mode 100644 share/forge-1.20.1/src/main/resources/pack.mcmeta create mode 100644 share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt create mode 100644 share/neoforge-1.21.1/build.gradle.kts create mode 100644 share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt create mode 100644 share/neoforge-1.21.1/src/main/resources/META-INF/neoforge.mods.toml create mode 100644 share/neoforge-1.21.1/src/main/resources/pack.mcmeta create mode 100644 share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml new file mode 100644 index 000000000..287460f94 --- /dev/null +++ b/.github/workflows/connect-share-release.yml @@ -0,0 +1,195 @@ +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 + +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 }} + MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} + + 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: 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: Verify marketplace configuration + 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: Publish verified artifacts to Modrinth + 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 + 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 + done diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 8bd02be56..f5e0d55d0 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -57,6 +57,61 @@ jobs: 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 + loader: Fabric + artifact: connect-share-fabric-1.20.1-*.jar + - minecraft: 1.21.1 + project: fabric-1-21-1 + loader: Fabric + artifact: connect-share-fabric-1.21.1-*.jar + - minecraft: 1.20.1 + project: forge-1.20.1 + loader: Forge + artifact: connect-share-forge-1.20.1-*.jar + - minecraft: 1.21.1 + project: 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.project }}/build/libs/${{ matrix.artifact }} + !share/${{ matrix.project }}/build/libs/*-sources.jar + !share/${{ matrix.project }}/build/libs/*-dev-*.jar + !share/${{ matrix.project }}/build/libs/*-dev-shadow.jar + !share/${{ matrix.project }}/build/libs/*-unshaded.jar + !share/${{ matrix.project }}/build/libs/*-parent-shadow.jar + share-1-21-11: name: Connect Share / Minecraft 1.21.11 runs-on: ubuntu-latest diff --git a/README.md b/README.md index b13b6a709..b48ef0eb5 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ low latency edge proxies network nearest to you. Please refer to https://connect.minekube.com for more documentation. -## Connect Share Fabric mod +## Connect Share mod -Connect Share is an in-development client-side Fabric mod for Minecraft Java -1.21.11 and 26.2. It shares a singleplayer world through Minekube Connect or +Connect Share is an in-development client-side Fabric, Forge, and NeoForge mod. +It supports Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and +NeoForge 1.21.1. It shares a singleplayer world through Minekube Connect or directly between two modded clients without exposing Minecraft's listener to the LAN or internet. @@ -36,7 +37,16 @@ The current implementation provides: - exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; - explicit support for authenticated and unverified offline-mode guests; and -- isolated, self-contained Fabric artifacts for both supported game versions. +- compatibility checks before a friend requests access; +- follow-next-session intents that never interrupt active gameplay; and +- isolated, version-and-loader-labelled artifacts for every supported target. + +Fabric builds require Fabric API and Fabric Language Kotlin. Forge and NeoForge +builds require the installable Kotlin for Forge `-all.jar`. Marketplace release +metadata declares the matching dependencies so compatible launchers, including +Prism, can install them automatically. Connect Share is MIT licensed and may be +included in modpacks without asking for additional permission. See +[the player, privacy, and distribution guide](docs/connect-share.md). The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See diff --git a/build-logic/src/main/kotlin/Versions.kt b/build-logic/src/main/kotlin/Versions.kt index 75bbcb13f..9c7e5357e 100644 --- a/build-logic/src/main/kotlin/Versions.kt +++ b/build-logic/src/main/kotlin/Versions.kt @@ -45,6 +45,8 @@ object Versions { 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" 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 index cbf0356f3..200a2d1c1 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -138,7 +138,11 @@ synchronized DirectP2pHostInfo startHost( DirectP2pHostHandler handler) { ensureOpen(); if (hostConfig != null) { - throw new IllegalStateException("Connect Share direct host is already started"); + 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"); @@ -183,9 +187,6 @@ synchronized void publish(String invitation) { if (hostConfig == null || host == null) { throw new IllegalStateException("Connect Share direct host is not started"); } - if (this.invitation != null) { - throw new IllegalStateException("Connect Share invitation is already published"); - } this.invitation = requireInvitation(invitation); startMdns(); } @@ -325,7 +326,13 @@ private synchronized void startMdns() { return; } InetAddress address = MdnsAddressSelector.systemAddress(); - JmDNS started = JmDNS.create(address); + // 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 @@ -334,7 +341,6 @@ private synchronized void startMdns() { List ipv6Addresses = address instanceof Inet6Address ? Collections.singletonList((Inet6Address) address) : Collections.emptyList(); - String peerId = host.getPeerId().toBase58(); started.registerService(ServiceInfo.create( MDNS_SERVICE, peerId, @@ -355,6 +361,12 @@ private synchronized void startMdns() { } } + 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) { 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 index a1722a124..a389b3ad8 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -259,6 +259,37 @@ void discoveryNodeCanBecomeThePublishedHostWithoutChangingItsPeer() { 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 = @@ -273,6 +304,19 @@ void mdnsTxtLengthPrefixSupportsModernEd25519PeerIds() { 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 directNodeNeverAdvertisesOrAcceptsCircuitRelayAddresses() { host = new DirectP2pNode(); diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 79af80454..12b65c5e3 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,7 +1,9 @@ # Connect Share acceptance -Connect Share is built separately for Minecraft Java 1.21.11 on Java 21 and -Minecraft Java 26.2 on Java 25. Run this pass against both artifacts before +Connect Share is built separately for Fabric 1.20.1, 1.21.1, and 1.21.11, +Forge 1.20.1, and NeoForge 1.21.1 on a Java 21 build 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 @@ -12,13 +14,23 @@ image, or roll anything out to production. From the repository root: ```sh -./gradlew :share:fabric-1-21-11:build -./gradlew :share:fabric-26-2:build +./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 matching Fabric Loader, Fabric API, and Fabric Language Kotlin. +Marketplace installs must resolve the latter two automatically. + +For Forge or NeoForge, install the matching loader and Kotlin for Forge. A +manual install must use Kotlin for Forge's `-all.jar`; its plain Maven artifact +is only a compile/library artifact and is not recognized as the loader mod. +Marketplace installs must resolve Kotlin for Forge automatically. ## Identity reuse and import @@ -128,14 +140,16 @@ self-hosted libp2p relay. Inspect the final JARs: ```sh -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 +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: +Each final artifact must contain its loader metadata, version-specific mixin +configuration, `pack.mcmeta` where the loader expects one, and: -- `fabric.mod.json`; -- the version-specific Connect Share mixin JSON; - English and German translations; - `LICENSE`; - `com/minekube/connect/share/` classes; and @@ -146,6 +160,22 @@ 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 for each of the six packaged +artifacts. 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, status, approval, and a new ` joined the game` host-log +line. Startup or control-plane reachability alone does not pass. + ## Evidence to retain Record the host and guest Minecraft versions, Java versions, artifact SHA-256 diff --git a/docs/connect-share.md b/docs/connect-share.md new file mode 100644 index 000000000..46db3a199 --- /dev/null +++ b/docs/connect-share.md @@ -0,0 +1,86 @@ +# 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 to + join**. 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. + +**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, current server/world name, and joinable state can each be + hidden independently under **Privacy**. +- Each friend can be set to **Ask Every Time**, **Auto-Accept**, or **Never + Allow**. The default is Ask 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 is opt-in on both sides because it can reveal public IP + addresses to that friend. Direct LAN addresses, endpoint tokens, invitation + capabilities, and private keys are never shown 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. + +## 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. + +Forge and NeoForge reuse the loader-neutral Kotlin core and version-specific +Minecraft UI/bridge adapters. Their packaged artifacts pass the same real +two-client Prism host/join gate as the Fabric artifacts. diff --git a/settings.gradle.kts b/settings.gradle.kts index 87ef73136..ee34a5e0e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -72,6 +72,9 @@ pluginManagement { maven("https://maven.fabricmc.net/") { name = "Fabric" } + maven("https://maven.neoforged.net/releases") { + name = "NeoForged" + } gradlePluginPortal() } repositories { @@ -84,6 +87,8 @@ pluginManagement { 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") @@ -105,6 +110,14 @@ if (!gradle.startParameter.projectProperties.containsKey("skip-share")) { 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 index 9e1d69952..9c190ad75 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -107,3 +107,17 @@ redesigned for Kotlin. supply `LIVE_DATA`, `LIVE_PORT_FILE`, and `LIVE_HOST_LOG`, then launch the guest against the port written to `LIVE_PORT_FILE`. The test succeeds only after the host logs a new ` joined the game` line. +- Invoke the live harness with `--rerun-tasks`. Its environment variables are + intentionally not task inputs, so an up-to-date result is not live evidence. +- Keep only one host and one guest identity active during a live run. Cloning a + Prism instance copies `share-libp2p-identity.key`; simultaneously advertising + that same peer identity from several processes makes mDNS routing ambiguous + and can produce misleading libp2p stream failures. +- Manually constructed Prism Forge/NeoForge components need correct + `cachedRequires` metadata and usually one online first launch to download + loader libraries. Kotlin for Forge must be installed from its `-all.jar`; + the smaller Maven compile artifact is not a discoverable loader mod. +- Legacy Forge's final reobfuscated JAR must contain its generated Mixin refmap + and name it from the loader-specific mixin config. Forge and NeoForge client + resources need a compatible `pack.mcmeta`, otherwise startup can stop at a + resource-pack warning before quick-play E2E begins. diff --git a/share/common/build.gradle.kts b/share/common/build.gradle.kts index 3527d1327..d2ee1ae00 100644 --- a/share/common/build.gradle.kts +++ b/share/common/build.gradle.kts @@ -1,9 +1,13 @@ +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) } @@ -11,6 +15,7 @@ java { kotlin { jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } dependencies { 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/FriendControlWire.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlWire.kt index e8d2a1f3b..ecc5229e4 100644 --- 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 @@ -32,6 +32,9 @@ enum class FriendActivityKind { 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 { @@ -89,8 +92,13 @@ object FriendControlWire { 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 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, @@ -302,7 +310,14 @@ object FriendControlWire { 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) @@ -311,7 +326,11 @@ object FriendControlWire { FriendControlResponse.SharedWorldJoinAccepted -> write(8) } } - return output.toByteArray() + return output.toByteArray().also { + require(it.size <= MAX_REQUEST_BYTES) { + "Friend response is too large" + } + } } fun decodeResponse( @@ -336,8 +355,17 @@ object FriendControlWire { 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() + }, ), ) } @@ -378,6 +406,40 @@ object FriendControlWire { write(encoded) } + private fun ByteArrayOutputStream.writeCompatibilityProfile( + profile: CompatibilityProfile, + ) { + require(profile.requiredMods.size <= MAX_REQUIRED_MODS) { + "Compatibility profile has too many required mods" + } + 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()) } @@ -470,6 +532,39 @@ object FriendControlWire { return bytes[position++].toInt() and 0xff } + fun readCompatibilityProfile(): CompatibilityProfile { + 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() + } + return CompatibilityProfile( + minecraftVersion = minecraftVersion, + loader = loader, + requiredMods = mods, + pack = pack, + ) + } + fun ensure(condition: Boolean) { if (!condition) { invalid() 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 index 0f93d8cf8..72b66deb9 100644 --- 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 @@ -28,11 +28,34 @@ 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 canJoinAutomatically: Boolean = false, -) + 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, @@ -65,6 +88,17 @@ data class PendingFriendRemoval( 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 @@ -86,6 +120,11 @@ sealed interface FriendStoreError { 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( @@ -114,6 +153,13 @@ class FriendStore( 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, @@ -181,6 +227,9 @@ class FriendStore( 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 } @@ -205,7 +254,9 @@ class FriendStore( permissions = (existing?.permissions ?: FriendPermissions()) .let { permissions -> if (allowAutomaticJoin) { - permissions.copy(canJoinAutomatically = true) + permissions.copy( + accessPolicy = FriendAccessPolicy.AUTO_ACCEPT, + ) } else { permissions } @@ -273,6 +324,48 @@ class FriendStore( 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): Boolean { val current = read() @@ -349,7 +442,17 @@ class FriendStore( if (removals.size > MAX_FRIENDS) { throw IOException("Friends file contains too many removals") } - return StoreData(friends, 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) { @@ -372,6 +475,19 @@ class FriendStore( ), ) + 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): SavedFriend { val peerId = json.requiredString("peerId") val publicKey = json.requiredString("publicKey") @@ -397,8 +513,13 @@ class FriendStore( permissions.requiredBoolean("notifyWhenOnline"), canSeeMyWorlds = permissions.requiredBoolean("canSeeMyWorlds"), - canJoinAutomatically = - permissions.requiredBoolean("canJoinAutomatically"), + accessPolicy = permissions.optionalString("accessPolicy") + ?.let(FriendAccessPolicy::valueOf) + ?: if (permissions.requiredBoolean("canJoinAutomatically")) { + FriendAccessPolicy.AUTO_ACCEPT + } else { + FriendAccessPolicy.ASK_EVERY_TIME + }, ) val relationshipStatus = json .optionalString("relationshipStatus") @@ -431,6 +552,9 @@ class FriendStore( 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 -> @@ -451,6 +575,19 @@ class FriendStore( 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( @@ -473,10 +610,7 @@ class FriendStore( JsonObject().apply { addProperty("notifyWhenOnline", permissions.notifyWhenOnline) addProperty("canSeeMyWorlds", permissions.canSeeMyWorlds) - addProperty( - "canJoinAutomatically", - permissions.canJoinAutomatically, - ) + addProperty("accessPolicy", permissions.accessPolicy.name) }, ) } @@ -539,7 +673,7 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" private const val MIN_WIRE_VERSION = 1 - private const val WIRE_VERSION = 2 + private const val WIRE_VERSION = 4 private const val MAX_FRIENDS = 256 private const val MAX_DISPLAY_NAME_LENGTH = 64 private val GSON = Gson() @@ -574,5 +708,6 @@ class FriendStore( 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/SharePreferencesStore.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/SharePreferencesStore.kt index a84b315ae..79144b29f 100644 --- 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 @@ -15,8 +15,16 @@ 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( @@ -33,11 +41,28 @@ class SharePreferencesStore( Files.readString(preferencesFile), JsonObject::class.java, ) ?: throw IOException("Share preferences are empty") - if (json.requiredInt("version") != WIRE_VERSION) { + 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) @@ -52,6 +77,15 @@ class SharePreferencesStore( 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, @@ -95,7 +129,8 @@ class SharePreferencesStore( companion object { const val FILE_NAME = "share-preferences.json" - private const val WIRE_VERSION = 1 + private const val MIN_WIRE_VERSION = 1 + private const val WIRE_VERSION = 2 private val GSON = Gson() } } 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/FriendControlWireTest.kt b/share/common/src/test/kotlin/com/minekube/connect/share/friend/FriendControlWireTest.kt index c210904ac..ea1190c1f 100644 --- 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 @@ -42,6 +42,19 @@ class FriendControlWireTest { 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"), 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 index 39ddc437e..91d69bba2 100644 --- 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 @@ -190,6 +190,51 @@ class FriendStoreTest { 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 `approved friend can be bound to an authenticated Minecraft identity`() { val store = FriendStore(tempDir) 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 index c9f73ce5e..017b83891 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -22,4 +23,21 @@ class SharePreferencesStoreTest { 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/fabric-1.20.1/build.gradle.kts b/share/fabric-1.20.1/build.gradle.kts new file mode 100644 index 000000000..1ff9e8802 --- /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 = 90L * 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 90 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..c1799c89d --- /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,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..d2e84b243 --- /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,99 @@ +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; + 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; + 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..34bc29bed --- /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,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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..2ff433f56 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } +} 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..06a07a2f3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft!!.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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..8daf811e0 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ConnectShare12111Client.kt @@ -0,0 +1,477 @@ +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.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 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()?.probeLan( + 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, + -> + 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + + 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 + + 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..09b96e09e --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.setFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..3f2aea4ff --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardNetworking.kt @@ -0,0 +1,84 @@ +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 invitation = runCatching { + buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS) + }.getOrNull() ?: return@registerGlobalReceiver + server.execute { + val proof = approvedJoins.consume( + player.gameProfile.name, + player.uuid, + ) ?: return@execute + receiver.receive( + invitation = invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + 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, + ) + 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..5824e04dc --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayload.kt @@ -0,0 +1,37 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import net.minecraft.resources.ResourceLocation +import net.minecraft.network.FriendlyByteBuf + +data class FriendCardPayload( + val invitation: String, +) { + 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) + } + + fun decode(buffer: FriendlyByteBuf): FriendCardPayload = + FriendCardPayload(buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS)) +} + +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..cd65ba8ba --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111LoginBridge.kt @@ -0,0 +1,178 @@ +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.DirectOnlineAuthenticationRequired +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.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 { + @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 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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + 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 = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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/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..ad23de8a3 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt @@ -0,0 +1,1192 @@ +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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page +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.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: String? = 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.safeMessage } + } + 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.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.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.description"), + 34, + ), + ) + + val state = friends.state.value + 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 = MAX_VISIBLE_RELATIONSHIPS, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + 82, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = 58 + index * 26 + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } + } + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20).build().apply { + setTooltip(pageTooltip) + }, + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds(width / 2 + 131, 14, 24, 20).build().apply { + setTooltip(pageTooltip) + }, + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 76), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + 34, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 84, + 310, + 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() + } + }, + ) + offlineMode = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 112, + 310, + 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( + width / 2 - 155, + 134, + 310, + 20, + Component.translatable("connect_share.join.internet"), + internetSelected, + { selected -> internetSelected = selected }, + ).apply { + setTooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + }, + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setValue(nameValue.ifBlank { friend.displayName }) + setResponder { + nameValue = it + refresh() + } + }, + ) + val notify = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 82, + 310, + 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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 104, + 310, + 20, + Component.translatable("connect_share.friends.share_worlds"), + friend.permissions.canSeeMyWorlds, + ), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 154), + ) + } + } + 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.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ), + ) + 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 53, height - 28, 102, 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.safeMessage + 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, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + 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.safeMessage + 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.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + + 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(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + 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.copy_my_link"), + 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..ea50255ba --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt @@ -0,0 +1,108 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft!!.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + y, + 310, + 20, + Component.translatable("connect_share.privacy.$key"), + selected, + changed, + ), + ) + } +} 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..b8fd99d56 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareSetupScreen.kt @@ -0,0 +1,151 @@ +package com.minekube.connect.share.fabric.v1_20_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.CycleButton +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 + + override fun init() { + val current = viewModel.state.value + minecraft!!.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.allowCommands) + } + + addRenderableWidget(centered(title, 18)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 36, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + ).withValues(ShareGameMode.entries) + .withInitialValue(current.options.gameMode) + .create( + width / 2 - 155, + 68, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 68, + 150, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + ).withValues((1..16).toList()) + .withInitialValue(current.options.maxGuests) + .create( + width / 2 - 75, + 96, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 126, + 310, + 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", + ), + ), + ) + }, + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ), + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft!!.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, message, font) + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} + +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..cc1b4b760 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt @@ -0,0 +1,210 @@ +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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 14)) + + val sharing = state.shareState as? ShareState.Sharing + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } + addRenderableWidget( + centered(summary, 32), + ) + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft!!.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 50, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { + sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds(width / 2 + 5, 50, 150, 20).build(), + ) + copyAddress.active = sharing?.address != null + + if (sharing != null) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.link_help", + ), + 78, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft!!.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + + val pending = state.pendingAdmissions + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 124 + index * 26 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 124 + visibleRows * 26, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 128, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft!!.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} 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..f9829e240 --- /dev/null +++ b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt @@ -0,0 +1,149 @@ +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 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, + ) + } 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, +) : 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..35817fbb1 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde 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": "Freundschafts- und Beitrittsanfragen", + "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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_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" +} 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..2d38a7018 --- /dev/null +++ b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "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": "Friends appear here when they're ready to play.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", + "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.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_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 friend", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the 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 once", + "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" +} 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..4bc15a011 --- /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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" 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\": " + + "\"Request to %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %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..7e8e41b0d --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +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 + +class FriendCardPayloadTest { + @Test + fun `signed friend card survives the network payload codec`() { + val invitation = "minekube://share/" + "signed-card".repeat(500) + val buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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/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..ed2ffbc12 --- /dev/null +++ b/share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft12111BridgeTest.kt @@ -0,0 +1,125 @@ +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.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 + + 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 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, + ): 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..8b962a75f --- /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 = 90L * 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 90 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..da981f25c --- /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,55 @@ +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; + + protected PauseScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addButton(CallbackInfo ci) { + Minecraft client = Minecraft.getInstance(); + if (!showPauseMenu + || !client.hasSingleplayerServer() + || disconnectButton == null + || !ConnectShareClient.isInstalled()) { + return; + } + + int shareY = disconnectButton.getY(); + disconnectButton.setY(shareY + 24); + connectShareButton = addRenderableWidget( + Button.builder( + Component.translatable( + ConnectShareClient.pauseButtonTranslationKey()), + button -> ConnectShareClient.openPauseScreen(this)) + .bounds(disconnectButton.getX(), shareY, 204, 20) + .build()); + } + + @Inject(method = "tick", at = @At("TAIL")) + private void connectShare$refreshButton(CallbackInfo ci) { + if (connectShareButton != null) { + connectShareButton.setMessage( + Component.translatable(ConnectShareClient.pauseButtonTranslationKey())); + } + } +} 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..a89542e96 --- /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,30 @@ +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.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 { + protected TitleScreenMixin(Component title) { + super(title); + } + + @Inject(method = "init", at = @At("TAIL")) + private void connectShare$addJoinButton(CallbackInfo ci) { + if (!ConnectShareClient.isInstalled()) { + return; + } + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.menu.join"), + button -> ConnectShareClient.openJoinScreen(this)) + .bounds(width - 106, 4, 102, 20) + .build()); + } +} 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..dec11f361 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } +} 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..3570aa65e --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft!!.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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..d24a6ae3c --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ConnectShare12111Client.kt @@ -0,0 +1,482 @@ +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.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()?.probeLan( + 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.toasts, + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + + 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..ddc95d129 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt @@ -0,0 +1,171 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +import com.minekube.connect.share.identity.CredentialSource +import java.nio.file.Path +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.EditBox +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() + addRenderableWidget(centered(title, 18)) + + val identity = state.identity + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.current", + identity?.endpoint ?: "…", + ), + 38, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.identity.sources", + identity?.endpointSource?.displayName() ?: "…", + identity?.tokenSource?.displayName() ?: "…", + ), + 52, + ), + ) + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.endpoint"), 72), + ) + endpointBox = EditBox( + font, + width / 2 - 100, + 84, + 200, + 20, + Component.translatable("connect_share.identity.endpoint"), + ).also { box -> + box.value = state.importDraft.endpoint + box.setResponder(viewModel::setImportEndpoint) + box.setEditable(state.importDraft.endpointEditable) + addRenderableWidget(box) + } + + addRenderableWidget( + centered(Component.translatable("connect_share.identity.token"), 110), + ) + tokenBox = EditBox( + font, + width / 2 - 100, + 122, + 200, + 20, + Component.translatable("connect_share.identity.token"), + ).also { box -> + box.value = state.importDraft.token + box.setResponder(viewModel::setImportToken) + box.setFormatter { text, _ -> + FormattedCharSequence.forward("•".repeat(text.length), Style.EMPTY) + } + box.setEditable(state.importDraft.tokenEditable) + addRenderableWidget(box) + } + + val save = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.save")) { + viewModel.importIdentity() + }.bounds(width / 2 - 155, 150, 150, 20).build(), + ) + val choose = addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.choose_file")) { + chooseTokenFile()?.let(viewModel::importTokenFile) + }.bounds(width / 2 + 5, 150, 150, 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(width / 2 - 100, 178, 200, 20).build(), + ) + reset.active = state.importDraft.endpointEditable && + state.importDraft.tokenEditable && + !state.operationInProgress + + state.safeMessage?.let { safeMessage -> + addRenderableWidget(centered(Component.literal(safeMessage), 204)) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 100, height - 28, 200, 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, 9, message, font) + } +} + +private fun CredentialSource.displayName(): String = + name.lowercase().replaceFirstChar(Char::titlecase) 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..3e71098d5 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardNetworking.kt @@ -0,0 +1,96 @@ +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, + ) ?: return@execute + receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = + proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + 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), + ) + 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..19d094f6e --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayload.kt @@ -0,0 +1,55 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +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, +) : 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 -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + ) + }, + ) + } +} + +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..d9cb7576b --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111LoginBridge.kt @@ -0,0 +1,179 @@ +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.DirectOnlineAuthenticationRequired +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.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( + DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + ), + ) + } + 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 = when (answer) { + AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") + AdmissionAnswer.CAPACITY -> Component.literal("This share is full") + AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") + else -> Component.literal("Host denied this connection") + } +} 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..151c75af9 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt @@ -0,0 +1,1187 @@ +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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page +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.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: String? = 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.safeMessage } + } + 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.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.MANAGE, + -> { + mode = Mode.FRIENDS + selectedPeerId = null + safeMessage = null + rebuildWidgets() + } + } + } + + override fun removed() { + scope?.cancel() + scope = null + super.removed() + } + + private fun buildFriends() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.title"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.description"), + 34, + ), + ) + + val state = friends.state.value + 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 = MAX_VISIBLE_RELATIONSHIPS, + ) + relationshipOffset = page.offset + if (relationships.isEmpty()) { + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.empty"), + 82, + ), + ) + } + page.items.forEachIndexed { index, relationship -> + val y = 58 + index * 26 + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } + } + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, + ), + ) + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20) + .tooltip(pageTooltip) + .build(), + ) + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset + rebuildWidgets() + }.bounds(width / 2 + 131, 14, 24, 20) + .tooltip(pageTooltip) + .build(), + ) + next.active = page.hasNext + } + + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), height - 76), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable(friendLinkState.translationKey), + ) { + copyMyFriendLink() + }.bounds(width / 2 - 155, height - 52, 150, 20) + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.friends.copy_my_link.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.add"), + ) { + mode = Mode.ADD + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + } + + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + + private fun buildAddFriend() { + addRenderableWidget( + centered( + Component.translatable("connect_share.friends.add"), + 16, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable("connect_share.friends.add_description"), + 34, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 58, + 310, + 20, + Component.translatable("connect_share.friends.name"), + ).apply { + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } + }, + ) + invitationBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 84, + 310, + 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() + } + }, + ) + offlineMode = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.join.offline"), + font, + ).pos(width / 2 - 155, 112) + .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(width / 2 - 155, 134) + .selected(internetSelected) + .onValueChange { _, selected -> + internetSelected = selected + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.join.internet.tooltip", + ), + ), + ) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 160), + ) + } + } + primaryButton = addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.send_request", + ), + ) { + createFriendRequest() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + secondaryButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.join_once"), + ) { + joinInvitation() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildManageFriend() { + val friend = selectedFriend() + if (friend == null) { + mode = Mode.FRIENDS + rebuildWidgets() + return + } + if (removeConfirmation) { + buildRemoveFriendConfirmation(friend) + return + } + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.manage_title", + friend.displayName, + ), + 16, + ), + ) + nameBox = addRenderableWidget( + EditBox( + font, + width / 2 - 155, + 50, + 310, + 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(width / 2 - 155, 82) + .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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, + ) + val shareWorlds = addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.friends.share_worlds"), + font, + ).pos(width / 2 - 155, 104) + .selected(friend.permissions.canSeeMyWorlds) + .build(), + ) + safeMessage().let { message -> + if (message != null) { + addRenderableWidget( + centered(Component.literal(message), 154), + ) + } + } + 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.updatePermissions( + friend.peerId, + FriendPermissions( + notifyWhenOnline = notify.selected(), + canSeeMyWorlds = shareWorlds.selected(), + accessPolicy = accessPolicy, + ), + ) + } + requestOperationInProgress = false + mode = Mode.FRIENDS + selectedPeerId = null + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.remove"), + ) { + removeConfirmation = true + rebuildWidgets() + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + refresh() + } + + private fun buildRemoveFriendConfirmation(friend: FriendSummary) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.friends.remove_confirm.title", + friend.displayName, + ), + 30, + ), + ) + addRenderableWidget( + centeredWrapped( + Component.translatable( + "connect_share.friends.remove_confirm.message", + ), + 58, + ), + ) + 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { + removeConfirmation = false + rebuildWidgets() + }.bounds(width / 2 + 53, height - 28, 102, 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.safeMessage + 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, + ) + } + requestOperationInProgress = false + if (peerId == null) { + rebuildWidgets() + return@launch + } + mode = Mode.FRIENDS + invitationValue = "" + nameValue = "" + 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, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val targetResult = friends.routeOutgoing( + peerId = peerId, + browser = browser, + authMode = DirectP2pAuthMode.OFFLINE, + ) + val target = targetResult.getOrNull() + if (target == null) { + requestFailed( + peerId, + targetResult.leftOrNull()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + 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(), + 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()?.safeMessage + ?: Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + val accepted = withContext(Dispatchers.IO) { + ConnectShareClient.friendCardReceiver().receive( + invitation = hostCard, + displayName = displayName, + authenticatedMinecraftUuid = null, + ) + } + if (accepted.isLeft()) { + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + } + requestStates.remove(peerId) + friends.reload() + safeMessage = Component.translatable( + "connect_share.friends.request_accepted", + displayName, + ).string + 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: String, + ) { + 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.safeMessage + 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(), + 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, + client, + 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.MANAGE -> nameValue.isNotBlank() + Mode.FRIENDS -> true + } + secondaryButton?.active = !joining && inputReady + invitationBox?.setEditable(!joining) + nameBox?.setEditable(!joining) + } + + private fun friendLabel(friend: FriendSummary): Component = when { + friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + Component.translatable( + "connect_share.friends.hosting_world", + friend.displayName, + friend.activityDescription ?: "Minecraft world", + ) + + friend.activityKind == FriendActivityKind.PLAYING_SERVER -> + Component.translatable( + "connect_share.friends.playing_server", + friend.displayName, + friend.activityDescription ?: "Minecraft server", + ) + + friend.onlineViaLan -> + Component.translatable( + "connect_share.friends.ready_lan", + friend.displayName, + friend.worldName ?: "", + ) + + friend.onlineViaConnect -> + Component.translatable( + "connect_share.friends.ready_connect", + friend.displayName, + friend.worldName ?: "", + ) + + friend.activityKind == FriendActivityKind.ONLINE -> + Component.translatable( + "connect_share.friends.online", + friend.displayName, + ) + + friend.connectAvailable -> + Component.translatable( + "connect_share.friends.saved_connect", + friend.displayName, + ) + + else -> + Component.translatable( + "connect_share.friends.saved_offline", + friend.displayName, + ) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "direct LAN" + Ingress.DIRECT_INTERNET -> "direct internet" + } + + 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(): String? = + safeMessage ?: friends.state.value.safeMessage + + 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, + ): MultiLineTextWidget = + MultiLineTextWidget( + width / 2 - CONTENT_WIDTH / 2, + y, + message, + font, + ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + + private enum class Mode { + FRIENDS, + ADD, + 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.copy_my_link"), + 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..ca93228a5 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.fabric.ConnectShareClient +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.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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft!!.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft!!.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(width / 2 - 155, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } +} 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..5453b9e9d --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareSetupScreen.kt @@ -0,0 +1,149 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import com.minekube.connect.share.ShareGameMode +import com.minekube.connect.share.fabric.ConnectShareClient +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.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 + + override fun init() { + val current = viewModel.state.value + minecraft!!.singleplayerServer?.let { server -> + viewModel.setGameMode(server.defaultGameType.toShareGameMode()) + viewModel.setAllowCheats(server.worldData.isAllowCommands) + } + + addRenderableWidget(centered(title, 18)) + addRenderableWidget( + centered( + Component.translatable("connect_share.setup.description"), + 36, + ), + ) + addRenderableWidget( + CycleButton.builder( + { mode: ShareGameMode -> + Component.translatable("connect_share.game_mode.${mode.name.lowercase()}") + }, + ).withValues(ShareGameMode.entries) + .withInitialValue(current.options.gameMode) + .create( + width / 2 - 155, + 68, + 150, + 20, + Component.translatable("selectWorld.gameMode"), + ) { _, mode -> viewModel.setGameMode(mode) }, + ) + addRenderableWidget( + CycleButton.onOffBuilder(current.options.allowCheats) + .create( + width / 2 + 5, + 68, + 150, + 20, + Component.translatable("selectWorld.allowCommands"), + ) { _, allowed -> viewModel.setAllowCheats(allowed) }, + ) + addRenderableWidget( + CycleButton.builder( + { guests: Int -> Component.literal(guests.toString()) }, + ).withValues((1..16).toList()) + .withInitialValue(current.options.maxGuests) + .create( + width / 2 - 75, + 96, + 150, + 20, + Component.translatable("connect_share.setup.max_guests"), + ) { _, guests -> viewModel.setMaxGuests(guests) }, + ) + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.setup.internet"), + font, + ).pos(width / 2 - 155, 126) + .selected(current.options.allowInternetDirect) + .onValueChange { _, allowed -> + viewModel.setAllowInternetDirect(allowed) + } + .tooltip( + Tooltip.create( + Component.translatable( + "connect_share.setup.internet.tooltip", + ), + ), + ) + .build(), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.setup.persistence", + ), + 154, + ), + ) + startButton = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.setup.start"), + ) { + viewModel.start() + minecraft!!.setScreen(ShareStatusScreen(parent)) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_CANCEL) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 20) + .build(), + ) + refresh() + } + + 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, 9, message, font) + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} + +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..7ac012a5b --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt @@ -0,0 +1,210 @@ +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.Ingress +import com.minekube.connect.share.admission.AdmissionPurpose +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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 + + override fun init() { + val state = viewModel.state.value + fingerprint = state.hashCode() + addRenderableWidget(centered(title, 14)) + + val sharing = state.shareState as? ShareState.Sharing + val publicAddress = sharing?.address + val summary = when { + publicAddress != null -> + Component.translatable( + "connect_share.status.address", + publicAddress, + ) + + sharing != null -> + Component.translatable("connect_share.status.direct_only") + + else -> Component.translatable(statusKey(state.shareState)) + } + addRenderableWidget( + centered(summary, 32), + ) + val copyInvitation = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_invitation"), + ) { + sharing?.invitation?.let( + minecraft!!.keyboardHandler::setClipboard, + ) + }.bounds(width / 2 - 155, 50, 150, 20).build(), + ) + copyInvitation.active = sharing?.invitation != null + val copyAddress = addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.copy_address"), + ) { + sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) + }.bounds(width / 2 + 5, 50, 150, 20).build(), + ) + copyAddress.active = sharing?.address != null + + if (sharing != null) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.link_help", + ), + 78, + ), + ) + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.connection_help", + ), + 94, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft!!.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.identity.manage")) { + minecraft!!.setScreen(EndpointIdentityScreen(this)) + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + ) + + val pending = state.pendingAdmissions + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.requests", + ), + 110, + ), + ) + val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + pending.take(visibleRows).forEachIndexed { index, request -> + val y = 124 + index * 26 + val identity = request.identity + val badge = when (identity) { + is AdmissionIdentity.Authenticated -> listOfNotNull( + identity.source.name.lowercase(), + identity.ingress.takeUnless { it == Ingress.CONNECT } + ?.displayName(), + ).joinToString(" · ") + + is AdmissionIdentity.UnverifiedOffline -> + "offline · ${identity.ingress.displayName()}" + } + val label = Component.translatable( + if (request.purpose == AdmissionPurpose.FRIEND) { + "connect_share.status.friend_request" + } else { + "connect_share.status.request" + }, + identity.name, + badge, + ) + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + label, + font, + ), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.allow")) { + viewModel.allow(request.requestId) + }.bounds(width / 2 + 51, y, 50, 20).build(), + ) + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.deny")) { + viewModel.deny(request.requestId) + }.bounds(width / 2 + 105, y, 50, 20).build(), + ) + } + if (pending.size > visibleRows) { + addRenderableWidget( + centered( + Component.translatable( + "connect_share.status.more", + pending.size - visibleRows, + ), + 124 + visibleRows * 26, + ), + ) + } else if (pending.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.status.waiting"), + 128, + ), + ) + } + + addRenderableWidget( + Button.builder(Component.translatable("connect_share.status.stop")) { + viewModel.stop() + minecraft!!.setScreen(parent) + }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 + 5, height - 28, 150, 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 centered(message: Component, y: Int): StringWidget { + val textWidth = font.width(message) + return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + } + + private fun Ingress.displayName(): String = when (this) { + Ingress.CONNECT -> "connect" + Ingress.DIRECT_LAN -> "lan" + Ingress.DIRECT_INTERNET -> "internet" + } + + 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.active" + ShareState.Stopping -> "connect_share.status.stopping" + is ShareState.Failed -> "connect_share.status.failed" + } + + private companion object { + const val CONTENT_WIDTH = 310 + } +} 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..12bef8b02 --- /dev/null +++ b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/VanillaMinecraft12111Transport.kt @@ -0,0 +1,149 @@ +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 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, + ) + } 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, +) : 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..35817fbb1 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Mit Freunden teilen", + "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.join": "Freunde", + "connect_share.setup.title": "Diese Welt teilen", + "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde 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": "Freundschafts- und Beitrittsanfragen", + "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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_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" +} 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..2d38a7018 --- /dev/null +++ b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/en_us.json @@ -0,0 +1,149 @@ +{ + "connect_share.menu.share": "Share with friends", + "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.join": "Friends", + "connect_share.setup.title": "Share this world", + "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "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": "Friends appear here when they're ready to play.", + "connect_share.friends.copy_my_link": "Copy my friend link", + "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", + "connect_share.friends.outgoing_request": "Request to %s", + "connect_share.friends.incoming_request": "Request from %s · %s", + "connect_share.friends.incoming_join_request": "%s wants you to join · %s", + "connect_share.friends.outgoing_request_active": "%s · request in progress", + "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.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_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 friend", + "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "connect_share.friends.name": "Friend's name", + "connect_share.friends.name_hint": "Name of the person who sent the 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 once", + "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" +} 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..1d972d435 --- /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\": \"Share this world\"" in + language, + ) + assertTrue( + "\"connect_share.status.copy_invitation\": " + + "\"Copy friend link\"" 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\": " + + "\"Request to %s\"" in language, + ) + assertTrue( + "\"connect_share.friends.incoming_request\": " + + "\"Request from %s · %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..7fdf17982 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/FriendCardPayloadTest.kt @@ -0,0 +1,41 @@ +package com.minekube.connect.share.fabric.v1_21_1 + +import io.netty.buffer.Unpooled +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 buffer = FriendlyByteBuf(Unpooled.buffer()) + + FriendCardPayload.CODEC.encode( + buffer, + FriendCardPayload(invitation), + ) + + assertEquals( + invitation, + FriendCardPayload.CODEC.decode(buffer).invitation, + ) + } + + @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..a8d3ed5b8 --- /dev/null +++ b/share/fabric-1.21.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_21_1/Minecraft12111BridgeTest.kt @@ -0,0 +1,125 @@ +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.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 + + 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 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, + ): 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 index b3bfb8feb..2f992cacb 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { exclude(group = "io.netty") exclude(group = "org.jetbrains.kotlin") exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") } testImplementation(kotlin("test")) @@ -143,3 +144,16 @@ tasks.remapJar { 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 = 90L * 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 90 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } 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..e048d7291 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } +} 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..be256e22a --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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 index cf23b2c50..7476d1671 100644 --- 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 @@ -13,15 +13,23 @@ 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.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 @@ -43,6 +51,10 @@ 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 { @@ -66,9 +78,33 @@ class ConnectShare12111Client : ClientModInitializer { 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) @@ -97,12 +133,14 @@ class ConnectShare12111Client : ClientModInitializer { 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, @@ -193,6 +231,18 @@ class ConnectShare12111Client : ClientModInitializer { 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, @@ -200,6 +250,8 @@ class ConnectShare12111Client : ClientModInitializer { .shareState is ShareState.Sharing, worldName = worldNameSnapshot.get(), externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, ), ) ConnectShareClient.integratedWorldChanged( @@ -238,6 +290,7 @@ class ConnectShare12111Client : ClientModInitializer { ) 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, @@ -266,6 +319,131 @@ class ConnectShare12111Client : ClientModInitializer { 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.toastManager, + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 87f039212..60fd509ae 100644 --- 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 @@ -3,7 +3,7 @@ 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.FriendJoinApproval +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 @@ -12,8 +12,12 @@ 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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page 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 @@ -25,6 +29,7 @@ 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 @@ -65,6 +70,7 @@ class ShareJoinScreen( 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() @@ -151,16 +157,21 @@ class ShareJoinScreen( ) val state = friends.state.value - val incoming = state.incomingRequests.take( - MAX_VISIBLE_RELATIONSHIPS, - ) - val outgoing = state.outgoingRequests.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size, - ) - val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, + 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 = MAX_VISIBLE_RELATIONSHIPS, ) - if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { + relationshipOffset = page.offset + if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), @@ -168,123 +179,45 @@ class ShareJoinScreen( ), ) } - incoming.forEachIndexed { index, request -> + page.items.forEachIndexed { index, relationship -> val y = 58 + index * 26 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 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, - request.ingress.displayName(), - ), - font, - ).setMaxWidth(174), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.allow"), - ) { - ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.deny"), - ) { - ConnectShareClient.viewModel().deny(request.requestId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } } - outgoing.forEachIndexed { index, request -> - val y = 58 + (incoming.size + index) * 26 - val deliveryState = requestStates[request.peerId] - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 20, - outgoingRequestLabel( - request.displayName, - deliveryState, - ), - font, + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, ), ) - addRenderableWidget( - Button.builder( - Component.translatable( - deliveryState?.translationKey - ?: "connect_share.friends.retry_request", - ), - ) { - deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { - active = deliveryState == null || - deliveryState == RequestDeliveryState.FAILED - }, - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.cancel_request", - ), - ) { - cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) - } - saved.forEachIndexed { index, friend -> - val y = - 58 + (incoming.size + outgoing.size + index) * 26 - val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 242 - actionWidth, - 20, - friendLabel(friend), - font, - ).setMaxWidth(242 - actionWidth), + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) - if (actionWidth > 0) { - addRenderableWidget( - Button.builder( - Component.translatable( - if (friend.canRequestJoin) { - "connect_share.friends.request_join" - } else { - "connect_share.join.join" - }, - ), - ) { - if (friend.canRequestJoin) requestToJoin(friend.peerId) - else joinSaved(friend.peerId) - }.bounds(width / 2 + 1, y, 86, 20).build(), - ) - } - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds(width / 2 + 131, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) + next.active = page.hasNext } safeMessage().let { message -> @@ -319,13 +252,145 @@ class ShareJoinScreen( rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .bounds(width / 2 + 5, height - 28, 150, 20) .build(), ) } + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + private fun buildAddFriend() { addRenderableWidget( centered( @@ -497,20 +562,23 @@ class ShareJoinScreen( .selected(friend.permissions.notifyWhenOnline) .build(), ) - val autoJoin = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.friends.auto_join"), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.permissions.canJoinAutomatically) - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.friends.auto_join.tooltip", - ), - ), - ) - .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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, ) val shareWorlds = addRenderableWidget( Checkbox.builder( @@ -543,8 +611,7 @@ class ShareJoinScreen( FriendPermissions( notifyWhenOnline = notify.selected(), canSeeMyWorlds = shareWorlds.selected(), - canJoinAutomatically = - autoJoin.selected(), + accessPolicy = accessPolicy, ), ) } @@ -609,13 +676,33 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { removeConfirmation = false rebuildWidgets() - }.bounds(width / 2 + 5, height - 28, 150, 20).build(), + }.bounds(width / 2 + 53, height - 28, 102, 20).build(), ) } @@ -664,7 +751,10 @@ class ShareJoinScreen( } } - private fun requestToJoin(peerId: String) { + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { if (joining) return joining = true joiningPeerId = peerId @@ -672,55 +762,37 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - val target = friends.routeFriendControl( + ConnectShareClient.friendJoinOrchestrator().request( peerId, - browser, - DirectP2pAuthMode.OFFLINE, - ).getOrNull() - if (target == null) { - joining = false - safeMessage = Component.translatable( - "connect_share.friends.friend_unreachable", - ).string - rebuildWidgets() - return@launch - } - ConnectShareClient.friendRequestClient().requestJoin( - target, FriendJoinRequest( requestId = UUID.randomUUID(), playerName = minecraft.user.name, playerUuid = minecraft.user.profileId, ), + allowModMismatch = allowModMismatch, ).fold( ifLeft = { failure -> joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, - ifRight = { approval -> - when (approval) { - is FriendJoinApproval.ExternalServer -> - connect(GuestJoinTarget.Connect(approval.address)) - FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.safeMessage + rebuildWidgets() } }, + ifRight = ::connect, ) } } - private suspend fun joinApprovedWorld(peerId: String) { - friends.join( - peerId = peerId, - browser = browser, - authMode = authMode(), - ownConnectAddress = ConnectShareClient.connectPublicAddress(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, - ) - } - private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -1078,6 +1150,20 @@ class ShareJoinScreen( 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, ) { 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..f211e2d40 --- /dev/null +++ b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric.v1_21_11 + +import com.minekube.connect.share.fabric.ConnectShareClient +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.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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(width / 2 - 155, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } +} 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 index f3c900d47..3bf49f46b 100644 --- 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 @@ -103,6 +103,13 @@ class ShareSetupScreen( minecraft.setScreen(ShareStatusScreen(parent)) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { onClose() } .bounds(width / 2 + 5, height - 28, 150, 20) 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 index 387f40dce..fa4f627ae 100644 --- 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 @@ -77,10 +77,15 @@ class ShareStatusScreen( ) } + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions 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 index c5885433f..35817fbb1 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", @@ -60,11 +61,20 @@ "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 sein Connect-Endpunkt ist nicht erreichbar.", + "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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } 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 index 3daee1fb2..2d38a7018 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", @@ -60,11 +61,20 @@ "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 their Connect endpoint unavailable.", + "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.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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } diff --git a/share/fabric-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index c687ff810..1bc566ca0 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { exclude(group = "io.netty") exclude(group = "org.jetbrains.kotlin") exclude(group = "org.jetbrains.kotlinx") + exclude(group = "com.google.errorprone", module = "javac") } testImplementation(kotlin("test")) @@ -144,3 +145,16 @@ tasks.test { .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 = 90L * 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 90 MiB release budget ($bytes bytes)" } + } +} +tasks.check { dependsOn(verifyConnectShareArtifactSize) } 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..25578a66f --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/BlockedFriendsScreen.kt @@ -0,0 +1,70 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +import net.minecraft.client.gui.components.Button +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")) { + override fun init() { + val friends = ConnectShareClient.friendsViewModel() + addRenderableWidget( + StringWidget( + width / 2 - font.width(title) / 2, + 16, + font.width(title), + 20, + title, + font, + ), + ) + friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> + val y = 48 + index * 26 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 202, + 20, + Component.literal(blocked.displayName), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.unblock"), + ) { + friends.unblock(blocked.peerId) + rebuildWidgets() + }.bounds(width / 2 + 51, y, 104, 20).build(), + ) + } + if (friends.state.value.blocked.isEmpty()) { + val empty = Component.translatable( + "connect_share.privacy.blocked_empty", + ) + addRenderableWidget( + StringWidget( + width / 2 - font.width(empty) / 2, + 70, + font.width(empty), + 20, + empty, + font, + ), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } +} 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..0fb980014 --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/CompatibilityMismatchScreen.kt @@ -0,0 +1,109 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.FriendJoinAttemptFailure +import com.minekube.connect.share.friend.CompatibilityDifference +import net.minecraft.client.gui.components.Button +import net.minecraft.client.gui.components.MultiLineTextWidget +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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 20, + title, + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 48, + Component.literal(failure.safeMessage), + font, + ).setMaxWidth(310).setCentered(true), + ) + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 78, + Component.literal(details()), + font, + ).setMaxWidth(310), + ) + 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(width / 2 - 155, height - 76, 310, 20).build(), + ) + } + if (failure.canTryAnyway) { + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.compatibility.try_anyway", + ), + ) { + minecraft.gui.setScreen(parent) + tryAnyway() + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) + } + addRenderableWidget( + Button.builder(CommonComponents.GUI_BACK) { onClose() } + .bounds( + if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, + height - 52, + 150, + 20, + ).build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun details(): String = failure.report.differences + .take(MAX_VISIBLE_DIFFERENCES) + .joinToString("\n") { difference -> + when (difference) { + is CompatibilityDifference.MinecraftVersion -> + "Minecraft: you ${difference.local}, host ${difference.remote}" + is CompatibilityDifference.Loader -> + "Loader: you ${difference.local.name.lowercase()}, " + + "host ${difference.remote.name.lowercase()}" + is CompatibilityDifference.MissingLocal -> + "Install ${difference.modId} ${difference.remoteVersion}" + is CompatibilityDifference.MissingRemote -> + "Host is missing ${difference.modId} ${difference.localVersion}" + is CompatibilityDifference.ModVersion -> + "${difference.modId}: you ${difference.local}, " + + "host ${difference.remote}" + } + } + + private companion object { + const val MAX_VISIBLE_DIFFERENCES = 5 + } +} 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 index 3de8ee6ce..43492fc96 100644 --- 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 @@ -13,15 +13,23 @@ 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.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 @@ -43,6 +51,10 @@ 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 { @@ -66,9 +78,33 @@ class ConnectShare262Client : ClientModInitializer { 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) @@ -97,12 +133,14 @@ class ConnectShare262Client : ClientModInitializer { 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, @@ -193,6 +231,18 @@ class ConnectShare262Client : ClientModInitializer { 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, @@ -200,6 +250,8 @@ class ConnectShare262Client : ClientModInitializer { .shareState is ShareState.Sharing, worldName = worldNameSnapshot.get(), externalServerName = externalServer?.name, + sessionEpoch = activityEpochSnapshot.get(), + compatibility = compatibilityProfile, ), ) ConnectShareClient.integratedWorldChanged( @@ -238,6 +290,7 @@ class ConnectShare262Client : ClientModInitializer { ) 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(), @@ -266,6 +319,131 @@ class ConnectShare262Client : ClientModInitializer { 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.safeMessage, + ) + } + }, + 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 -> followToast( + minecraft, + "connect_share.notification.follow_expired", + "connect_share.notification.follow_expired_detail", + action.displayName, + ) + + is FollowAction.Cancelled -> Unit + } + } + } + + 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, + ) { + SystemToast.add( + minecraft.gui.toastManager(), + SystemToast.SystemToastId(), + Component.translatable(titleKey, value), + detailKey?.let { Component.translatable(it, value) } + ?: Component.literal(value), + ) + } + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 0321f2f33..78bb37090 100644 --- 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 @@ -3,7 +3,7 @@ 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.FriendJoinApproval +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 @@ -12,8 +12,12 @@ 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.IncomingFriendRequestSummary +import com.minekube.connect.share.fabric.ui.OutgoingFriendRequestSummary import com.minekube.connect.share.fabric.ui.FriendsViewModel +import com.minekube.connect.share.fabric.ui.page 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 @@ -25,6 +29,7 @@ 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 @@ -65,6 +70,7 @@ class ShareJoinScreen( 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() @@ -151,16 +157,21 @@ class ShareJoinScreen( ) val state = friends.state.value - val incoming = state.incomingRequests.take( - MAX_VISIBLE_RELATIONSHIPS, - ) - val outgoing = state.outgoingRequests.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size, - ) - val saved = state.friends.take( - MAX_VISIBLE_RELATIONSHIPS - incoming.size - outgoing.size, + 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 = MAX_VISIBLE_RELATIONSHIPS, ) - if (incoming.isEmpty() && outgoing.isEmpty() && saved.isEmpty()) { + relationshipOffset = page.offset + if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), @@ -168,123 +179,45 @@ class ShareJoinScreen( ), ) } - incoming.forEachIndexed { index, request -> + page.items.forEachIndexed { index, relationship -> val y = 58 + index * 26 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 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, - request.ingress.displayName(), - ), - font, - ).setMaxWidth(174), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.allow"), - ) { - ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.deny"), - ) { - ConnectShareClient.viewModel().deny(request.requestId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) + when (relationship) { + is RelationshipRow.Incoming -> + addIncomingRow(relationship.request, y) + + is RelationshipRow.Outgoing -> + addOutgoingRow(relationship.request, y) + + is RelationshipRow.Friend -> + addFriendRow(relationship.friend, y) + } } - outgoing.forEachIndexed { index, request -> - val y = 58 + (incoming.size + index) * 26 - val deliveryState = requestStates[request.peerId] - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 174, - 20, - outgoingRequestLabel( - request.displayName, - deliveryState, - ), - font, + if (page.pageCount > 1) { + val pageTooltip = Tooltip.create( + Component.translatable( + "connect_share.friends.page", + page.pageNumber, + page.pageCount, ), ) - addRenderableWidget( - Button.builder( - Component.translatable( - deliveryState?.translationKey - ?: "connect_share.friends.retry_request", - ), - ) { - deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { - active = deliveryState == null || - deliveryState == RequestDeliveryState.FAILED - }, - ) - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.cancel_request", - ), - ) { - cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), - ) - } - saved.forEachIndexed { index, friend -> - val y = - 58 + (incoming.size + outgoing.size + index) * 26 - val actionWidth = if (friend.canRequestJoin || friend.canJoinNow) 86 else 0 - addRenderableWidget( - StringWidget( - width / 2 - 155, - y, - 242 - actionWidth, - 20, - friendLabel(friend), - font, - ).setMaxWidth(242 - actionWidth), + val previous = addRenderableWidget( + Button.builder(Component.literal("‹")) { + relationshipOffset = page.previousOffset ?: 0 + rebuildWidgets() + }.bounds(width / 2 - 155, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) - if (actionWidth > 0) { - addRenderableWidget( - Button.builder( - Component.translatable( - if (friend.canRequestJoin) { - "connect_share.friends.request_join" - } else { - "connect_share.join.join" - }, - ), - ) { - if (friend.canRequestJoin) requestToJoin(friend.peerId) - else joinSaved(friend.peerId) - }.bounds(width / 2 + 1, y, 86, 20).build(), - ) - } - addRenderableWidget( - Button.builder( - Component.translatable( - "connect_share.friends.manage", - ), - ) { - selectedPeerId = friend.peerId - nameValue = friend.displayName - mode = Mode.MANAGE - safeMessage = null + previous.active = page.hasPrevious + val next = addRenderableWidget( + Button.builder(Component.literal("›")) { + relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds(width / 2 + 131, 14, 24, 20) + .tooltip(pageTooltip) + .build(), ) + next.active = page.hasNext } safeMessage().let { message -> @@ -319,13 +252,145 @@ class ShareJoinScreen( rebuildWidgets() }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 28, 150, 20) + .build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .bounds(width / 2 + 5, height - 28, 150, 20) .build(), ) } + private fun addIncomingRow( + request: IncomingFriendRequestSummary, + y: Int, + ) { + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 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, + request.ingress.displayName(), + ), + font, + ).setMaxWidth(174), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { + ConnectShareClient.viewModel().allow(request.requestId) + }.bounds(width / 2 + 23, y, 62, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { + ConnectShareClient.viewModel().deny(request.requestId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addOutgoingRow( + request: OutgoingFriendRequestSummary, + y: Int, + ) { + val deliveryState = requestStates[request.peerId] + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 174, + 20, + outgoingRequestLabel(request.displayName, deliveryState), + font, + ), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + deliveryState?.translationKey + ?: "connect_share.friends.retry_request", + ), + ) { + deliverOutgoing(request.peerId) + }.bounds(width / 2 + 23, y, 62, 20).build().apply { + active = deliveryState == null || + deliveryState == RequestDeliveryState.FAILED + }, + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.friends.cancel_request", + ), + ) { + cancelOutgoing(request.peerId) + }.bounds(width / 2 + 89, y, 66, 20).build(), + ) + } + + private fun addFriendRow(friend: FriendSummary, y: Int) { + val actionWidth = 86 + addRenderableWidget( + StringWidget( + width / 2 - 155, + y, + 242 - actionWidth, + 20, + friendLabel(friend), + font, + ).setMaxWidth(242 - actionWidth), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + when { + friend.canRequestJoin -> + "connect_share.friends.request_join" + friend.canJoinNow -> "connect_share.join.join" + friend.following -> + "connect_share.friends.cancel_follow" + else -> "connect_share.friends.follow" + }, + ), + ) { + when { + friend.canRequestJoin -> requestToJoin(friend.peerId) + friend.canJoinNow -> joinSaved(friend.peerId) + friend.following -> friends.cancelFollow(friend.peerId) + else -> friends.follow(friend.peerId) + } + rebuildWidgets() + }.bounds(width / 2 + 1, y, 86, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.friends.manage"), + ) { + selectedPeerId = friend.peerId + nameValue = friend.displayName + mode = Mode.MANAGE + safeMessage = null + rebuildWidgets() + }.bounds(width / 2 + 91, y, 64, 20).build(), + ) + } + private fun buildAddFriend() { addRenderableWidget( centered( @@ -497,20 +562,23 @@ class ShareJoinScreen( .selected(friend.permissions.notifyWhenOnline) .build(), ) - val autoJoin = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.friends.auto_join"), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.permissions.canJoinAutomatically) - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.friends.auto_join.tooltip", - ), - ), - ) - .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( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable("connect_share.friends.access"), + ) { _, selected -> accessPolicy = selected }, ) val shareWorlds = addRenderableWidget( Checkbox.builder( @@ -543,8 +611,7 @@ class ShareJoinScreen( FriendPermissions( notifyWhenOnline = notify.selected(), canSeeMyWorlds = shareWorlds.selected(), - canJoinAutomatically = - autoJoin.selected(), + accessPolicy = accessPolicy, ), ) } @@ -609,13 +676,33 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds(width / 2 - 155, height - 28, 98, 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 + nameValue = "" + rebuildWidgets() + } + }.bounds(width / 2 - 51, height - 28, 98, 20).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { removeConfirmation = false rebuildWidgets() - }.bounds(width / 2 + 5, height - 28, 150, 20).build(), + }.bounds(width / 2 + 53, height - 28, 102, 20).build(), ) } @@ -664,7 +751,10 @@ class ShareJoinScreen( } } - private fun requestToJoin(peerId: String) { + private fun requestToJoin( + peerId: String, + allowModMismatch: Boolean = false, + ) { if (joining) return joining = true joiningPeerId = peerId @@ -672,55 +762,37 @@ class ShareJoinScreen( safeMessage = null refresh() scope?.launch { - val target = friends.routeFriendControl( + ConnectShareClient.friendJoinOrchestrator().request( peerId, - browser, - DirectP2pAuthMode.OFFLINE, - ).getOrNull() - if (target == null) { - joining = false - safeMessage = Component.translatable( - "connect_share.friends.friend_unreachable", - ).string - rebuildWidgets() - return@launch - } - ConnectShareClient.friendRequestClient().requestJoin( - target, FriendJoinRequest( requestId = UUID.randomUUID(), playerName = minecraft.user.name, playerUuid = minecraft.user.profileId, ), + allowModMismatch = allowModMismatch, ).fold( ifLeft = { failure -> joining = false - safeMessage = failure.safeMessage - rebuildWidgets() - }, - ifRight = { approval -> - when (approval) { - is FriendJoinApproval.ExternalServer -> - connect(GuestJoinTarget.Connect(approval.address)) - FriendJoinApproval.SharedWorld -> joinApprovedWorld(peerId) + if (failure is FriendJoinAttemptFailure.Compatibility) { + minecraft.gui.setScreen( + CompatibilityMismatchScreen( + parent = this@ShareJoinScreen, + failure = failure, + tryAnyway = { + requestToJoin(peerId, true) + }, + ), + ) + } else { + safeMessage = failure.safeMessage + rebuildWidgets() } }, + ifRight = ::connect, ) } } - private suspend fun joinApprovedWorld(peerId: String) { - friends.join( - peerId = peerId, - browser = browser, - authMode = authMode(), - ownConnectAddress = ConnectShareClient.connectPublicAddress(), - ).fold( - ifLeft = ::joinFailed, - ifRight = ::connect, - ) - } - private fun createFriendRequest() { val activeScope = scope ?: return if ( @@ -1077,6 +1149,20 @@ class ShareJoinScreen( 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, ) { 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..c5f3f5b4c --- /dev/null +++ b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt @@ -0,0 +1,107 @@ +package com.minekube.connect.share.fabric.v26_2 + +import com.minekube.connect.share.fabric.ConnectShareClient +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.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() { + addRenderableWidget( + MultiLineTextWidget( + width / 2 - 155, + 18, + Component.translatable("connect_share.privacy.description"), + font, + ).setMaxWidth(310).setCentered(true), + ) + val privacy = viewModel.state.value.presencePrivacy + privacyToggle("online", 66, privacy.showOnline) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showOnline = value), + ) + } + privacyToggle("playing", 92, privacy.showPlaying) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showPlaying = value), + ) + } + privacyToggle( + "current_server", + 118, + privacy.showCurrentServer, + ) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy( + showCurrentServer = value, + ), + ) + } + privacyToggle("joinable", 144, privacy.showJoinable) { value -> + viewModel.setPresencePrivacy( + viewModel.state.value.presencePrivacy.copy(showJoinable = value), + ) + } + 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(width / 2 - 75, height - 76, 150, 20).build(), + ) + addRenderableWidget( + Button.builder( + Component.translatable( + "connect_share.privacy.blocked", + ConnectShareClient.friendsViewModel().state.value.blocked.size, + ), + ) { + minecraft.gui.setScreen(BlockedFriendsScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) + addRenderableWidget( + Button.builder(CommonComponents.GUI_DONE) { onClose() } + .bounds(width / 2 - 75, height - 28, 150, 20) + .build(), + ) + } + + override fun onClose() { + minecraft.gui.setScreen(parent) + } + + private fun privacyToggle( + key: String, + y: Int, + selected: Boolean, + changed: (Boolean) -> Unit, + ) { + addRenderableWidget( + Checkbox.builder( + Component.translatable("connect_share.privacy.$key"), + font, + ).pos(width / 2 - 155, y) + .selected(selected) + .onValueChange { _, value -> changed(value) } + .build(), + ) + } +} 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 index db90bca2e..5c92adf39 100644 --- 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 @@ -103,6 +103,13 @@ class ShareSetupScreen( minecraft.gui.setScreen(ShareStatusScreen(parent)) }.bounds(width / 2 - 155, height - 28, 150, 20).build(), ) + addRenderableWidget( + Button.builder( + Component.translatable("connect_share.privacy.title"), + ) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(CommonComponents.GUI_CANCEL) { onClose() } .bounds(width / 2 + 5, height - 28, 150, 20) 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 index 7de617042..a86bc984c 100644 --- 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 @@ -77,10 +77,15 @@ class ShareStatusScreen( ) } + addRenderableWidget( + Button.builder(Component.translatable("connect_share.privacy.title")) { + minecraft.gui.setScreen(SharePrivacyScreen(this)) + }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + ) addRenderableWidget( Button.builder(Component.translatable("connect_share.identity.manage")) { minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds(width / 2 + 5, height - 52, 150, 20).build(), ) val pending = state.pendingAdmissions 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 index c5885433f..35817fbb1 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", @@ -60,11 +61,20 @@ "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 sein Connect-Endpunkt ist nicht erreichbar.", + "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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } 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 index 3daee1fb2..2d38a7018 100644 --- 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 @@ -45,6 +45,7 @@ "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": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", @@ -60,11 +61,20 @@ "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 their Connect endpoint unavailable.", + "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.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_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", @@ -82,11 +92,27 @@ "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", @@ -113,5 +139,11 @@ "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.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" } diff --git a/share/fabric-common/build.gradle.kts b/share/fabric-common/build.gradle.kts index 000e4d29b..c3354ae2f 100644 --- a/share/fabric-common/build.gradle.kts +++ b/share/fabric-common/build.gradle.kts @@ -1,9 +1,13 @@ +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) } @@ -11,6 +15,7 @@ java { kotlin { jvmToolchain(21) + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } dependencies { 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 index 30597c0b4..7c8e7e5eb 100644 --- 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 @@ -25,6 +25,10 @@ data class ConnectShareInstallation( 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, @@ -113,6 +117,15 @@ object ConnectShareClient { 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() 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 index 278d256a5..2e625f0f5 100644 --- 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 @@ -22,23 +22,26 @@ internal class FabricDirectPeerRuntime private constructor( ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( - browser = FabricShareBrowser(dataDirectory), - ingress = FabricDirectShareIngress( - dataDirectory = dataDirectory, - displayName = displayName, - accessIdentityStore = accessIdentityStore, + 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, ), ) @@ -53,6 +56,8 @@ internal class FabricDirectPeerRuntime private constructor( dataDirectory = dataDirectory, displayName = displayName, ) + + private const val IDENTITY_FILE_NAME = "share-libp2p-identity.key" } } 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 index 7acf402ef..2cda42bfb 100644 --- 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 @@ -29,6 +29,7 @@ class FabricDirectShareIngress private constructor( private val accessIdentity: () -> ShareAccessIdentity, private val displayName: () -> String, private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, + private val closeNodeOnHandleClose: Boolean, ) : DirectShareIngress { constructor( dataDirectory: Path, @@ -45,20 +46,22 @@ class FabricDirectShareIngress private constructor( accessIdentity = accessIdentityStore::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, + closeNodeOnHandleClose = true, ) internal constructor( node: FabricDirectNode, dataDirectory: Path, + accessIdentityStore: ShareAccessIdentityStore = + ShareAccessIdentityStore(dataDirectory), displayName: () -> String, ) : this( nodeFactory = { node }, now = Instant::now, - accessIdentity = ShareAccessIdentityStore( - dataDirectory, - )::currentOrCreate, + accessIdentity = accessIdentityStore::currentOrCreate, displayName = displayName, localSocket = ::openTaggedLoopbackSocket, + closeNodeOnHandleClose = false, ) override suspend fun start( @@ -119,17 +122,22 @@ class FabricDirectShareIngress private constructor( options.allowInternetDirect && internetCandidates.isNotEmpty(), close = { - if (closed.compareAndSet(false, true)) { + if ( + closeNodeOnHandleClose && + closed.compareAndSet(false, true) + ) { node.close() } }, ) } catch (failure: Throwable) { - try { - node.close() - } catch (cleanupFailure: Throwable) { - if (cleanupFailure !== failure) { - failure.addSuppressed(cleanupFailure) + if (closeNodeOnHandleClose) { + try { + node.close() + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } } } throw failure @@ -155,6 +163,7 @@ class FabricDirectShareIngress private constructor( }, displayName = displayName, localSocket = localSocket, + closeNodeOnHandleClose = true, ) private fun openTaggedLoopbackSocket( 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 index 01dd48408..7b7985a2d 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -42,6 +43,7 @@ object FabricShareBootstrap { scope: CoroutineScope, dataDirectory: Path, minecraftVersion: String, + modVersion: String = "development", worldAvailable: Boolean, friendStore: FriendStore, playerCount: () -> Int, @@ -50,6 +52,7 @@ object FabricShareBootstrap { friendActivity: () -> FriendActivity = { FriendActivity(FriendActivityKind.ONLINE) }, + compatibilityProfile: () -> CompatibilityProfile? = { null }, friendJoinTarget: () -> String? = { null }, bridgeFactory: ( @@ -65,6 +68,7 @@ object FabricShareBootstrap { httpClient: OkHttpClient = OkHttpClient(), ): ConnectShareInstallation { val viewModelReference = AtomicReference() + val diagnostics = ShareJoinDiagnostics() val approvedJoins = ApprovedJoinTracker() val admission = AdmissionController( scope = scope, @@ -103,6 +107,7 @@ object FabricShareBootstrap { logger.warn("Connect Share preferences could not be loaded") SharePreferences() } + val preferences = AtomicReference(initialPreferences) val validator = WatchEndpointCredentialValidator( client = httpClient, watchUrl = watchHttpUrl(environment), @@ -127,6 +132,7 @@ object FabricShareBootstrap { receiver = friendCardReceiver, friendStore = friendStore, activity = friendActivity, + presencePrivacy = { preferences.get().presence }, joinTarget = friendJoinTarget, ) val gateway = ShareConnectionGateway.bind(friendRequestServer) @@ -200,10 +206,18 @@ object FabricShareBootstrap { initialWorldAvailable = worldAvailable, initialShareWithFriendsEnabled = initialPreferences.shareWithFriends, + initialPresencePrivacy = initialPreferences.presence, persistShareWithFriendsEnabled = { enabled -> - preferencesStore.save( - SharePreferences(shareWithFriends = 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, @@ -293,6 +307,15 @@ object FabricShareBootstrap { 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, @@ -301,6 +324,10 @@ object FabricShareBootstrap { friendCardReceiver = friendCardReceiver, friendRequestClient = friendRequestClient, friendPairingClient = friendPairingClient, + friendJoinOrchestrator = friendJoinOrchestrator, + diagnostics = diagnostics, + minecraftVersion = minecraftVersion, + modVersion = modVersion, approvedJoins = approvedJoins, controlPlane = controlPlane, directControlPlane = directControlPlane, 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/FriendActivityResolver.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendActivityResolver.kt index c1707ba5f..6874c1481 100644 --- 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 @@ -2,6 +2,7 @@ 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( @@ -9,15 +10,24 @@ object FriendActivityResolver { 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, ) - else -> FriendActivity(FriendActivityKind.ONLINE) } } 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/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt index c30378f18..14acae935 100644 --- 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 @@ -15,8 +15,10 @@ 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 @@ -40,6 +42,9 @@ class FriendRequestServer( private val activity: () -> FriendActivity = { FriendActivity(FriendActivityKind.ONLINE) }, + private val presencePrivacy: () -> PresencePrivacy = { + PresencePrivacy() + }, private val joinTarget: () -> String? = { null }, ) : FriendControlServer { override fun handle( @@ -101,10 +106,24 @@ class FriendRequestServer( ): CompletionStage = launchResponse { val friend = authenticatedFriend(context) ?: return@launchResponse FriendControlResponse.Invalid - val visible = if (friend.permissions.canSeeMyWorlds) { - activity() - } else { - FriendActivity(FriendActivityKind.ONLINE) + 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 || + privacy.showCurrentServer + }, + joinable = current.joinable && privacy.showJoinable && + friend.permissions.accessPolicy != + FriendAccessPolicy.NEVER_ALLOW, + ) } FriendControlResponse.Activity(visible) } @@ -115,6 +134,9 @@ class FriendRequestServer( ): 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 } @@ -196,6 +218,9 @@ class FriendRequestServer( 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() 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/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/ui/FriendsViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModel.kt index 360eb282b..ae282ce67 100644 --- 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 @@ -13,6 +13,8 @@ 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 @@ -20,6 +22,7 @@ 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 @@ -39,6 +42,7 @@ data class FriendSummary( val activityDescription: String? = null, val canRequestJoin: Boolean = false, val canJoinNow: Boolean = false, + val following: Boolean = false, ) data class OutgoingFriendRequestSummary( @@ -53,15 +57,23 @@ data class IncomingFriendRequestSummary( 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: String? = null, ) class FriendsViewModel( private val store: FriendStore, + private val followController: FollowNextSessionController = + FollowNextSessionController(), private val onRemovalQueued: () -> Unit = {}, ) { private var discovered: List = emptyList() @@ -139,6 +151,59 @@ class FriendsViewModel( }, ) + fun block(peerId: String): Boolean = + Either.catch { store.block(peerId) }.fold( + ifLeft = { + update { copy(safeMessage = FRIEND_BLOCK_FAILURE) } + false + }, + ifRight = { blocked -> + refresh() + if (blocked) 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 @@ -236,6 +301,9 @@ class FriendsViewModel( } }.getOrNull() + internal fun compatibilityFor(peerId: String): CompatibilityProfile? = + activities[peerId]?.compatibility + private fun refresh( preserveSafeMessage: Boolean = false, ) { @@ -273,6 +341,12 @@ class FriendsViewModel( ) }, incomingRequests = incomingRequests, + blocked = store.blocked().map { + BlockedFriendSummary( + peerId = it.peerId, + displayName = it.displayName, + ) + }, ) private fun update(transform: FriendsUiState.() -> FriendsUiState) { @@ -293,13 +367,16 @@ class FriendsViewModel( worldName = remote?.description, activityKind = activity?.kind, activityDescription = activity?.description, - canRequestJoin = - activity?.kind == FriendActivityKind.PLAYING_SERVER || - activity?.kind == FriendActivityKind.HOSTING_WORLD && - remote != null, + canRequestJoin = activity?.joinable == true && + ( + activity.kind == FriendActivityKind.PLAYING_SERVER || + activity.kind == FriendActivityKind.HOSTING_WORLD && + remote != null + ), canJoinNow = remote != null && activity?.kind != FriendActivityKind.PLAYING_SERVER && activity?.kind != FriendActivityKind.HOSTING_WORLD, + following = peerId in followController.state.value, ) } @@ -308,5 +385,9 @@ class FriendsViewModel( "Saved Connect Share friends could not be loaded" const val FRIEND_REMOVE_FAILURE = "This Connect Share friend could not be removed" + const val FRIEND_BLOCK_FAILURE = + "This Connect Share identity could not be blocked" + const val FRIEND_UNBLOCK_FAILURE = + "This Connect Share identity could not be unblocked" } } 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/ShareViewModel.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModel.kt index 52b58f751..75c775e09 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -23,6 +24,8 @@ 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, @@ -52,6 +55,7 @@ data class ShareUiState( 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, @@ -112,6 +116,8 @@ class ShareViewModel( 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, @@ -119,6 +125,7 @@ class ShareViewModel( private val operationDispatcher: CoroutineDispatcher = Dispatchers.IO, private val onIdentityChanged: suspend () -> Unit = {}, ) { + private val operationMutex = Mutex() private val mutableState = MutableStateFlow( ShareUiState( worldAvailable = initialWorldAvailable, @@ -129,6 +136,7 @@ class ShareViewModel( ), pendingAdmissions = pendingAdmissions.value, shareWithFriendsEnabled = initialShareWithFriendsEnabled, + presencePrivacy = initialPresencePrivacy, ), ) @@ -189,10 +197,22 @@ class ShareViewModel( } } + 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() } @@ -202,6 +222,7 @@ class ShareViewModel( fun stop() { scope.launch(context = operationDispatcher) { runOperation { + if (!canStopCurrentWorld()) return@runOperation try { setShareWithFriendsEnabled(false) } finally { @@ -220,6 +241,7 @@ class ShareViewModel( } kotlinx.coroutines.withContext(operationDispatcher) { runOperation { + if (!canStartCurrentWorld()) return@runOperation startCurrentWorld() } } @@ -343,7 +365,12 @@ class ShareViewModel( update { copy(safeMessage = failure.safeMessage) } }, ifRight = { - update { copy(safeMessage = null) } + update { + copy( + shareState = it, + safeMessage = null, + ) + } }, ) } @@ -354,24 +381,45 @@ class ShareViewModel( update { copy(safeMessage = failure.safeMessage) } }, ifRight = { - update { copy(safeMessage = null) } + update { + copy( + shareState = ShareState.Idle, + safeMessage = null, + ) + } }, ) } private suspend fun runOperation(operation: suspend () -> Unit) { - update { copy(operationInProgress = true) } - try { - operation() - } catch (cancellation: CancellationException) { - throw cancellation - } catch (_: Exception) { - update { copy(safeMessage = GENERIC_FAILURE_MESSAGE) } - } finally { - update { copy(operationInProgress = false) } + 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, + is ShareState.Failed, + -> false + + ShareState.Starting, + is ShareState.Sharing, + ShareState.Stopping, + -> true + } + private fun update(transform: ShareUiState.() -> ShareUiState) { mutableState.value = mutableState.value.transform() } @@ -409,6 +457,8 @@ class ShareViewModel( "Could not update Connect Share" const val IDENTITY_ACTIVE_MESSAGE = "Stop sharing before changing Connect credentials" + const val PREFERENCES_FAILURE_MESSAGE = + "Connect Share privacy settings could not be saved" } } 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 index 3b5da75e8..9db41151c 100644 --- 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 @@ -54,12 +54,57 @@ class FabricDirectPeerRuntimeTest { 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 @@ -99,7 +144,9 @@ class FabricDirectPeerRuntimeTest { timeout: Duration, ): DirectP2pProxy = error("not used") - override fun close() = Unit + override fun close() { + closes++ + } } private companion object { 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..ea9e961a9 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FollowNextSessionControllerTest.kt @@ -0,0 +1,128 @@ +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 `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/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/FriendRequestServerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt index 8d71d47dc..a72eb6767 100644 --- 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 @@ -13,6 +13,8 @@ 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 @@ -116,6 +118,35 @@ class FriendRequestServerTest { 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 { @@ -327,6 +358,79 @@ class FriendRequestServerTest { ) } + @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(), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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..8defd857e --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/LoadedCompatibilityProfileFactoryTest.kt @@ -0,0 +1,49 @@ +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 + +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) + } +} 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 index 70bab6180..998aad2c3 100644 --- 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 @@ -4,6 +4,7 @@ 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.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -12,6 +13,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.test.fail import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -42,6 +44,16 @@ class PrismFriendJoinE2ETest { .lineSequence() .count { joinedLine in it } 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()) @@ -57,15 +69,19 @@ class PrismFriendJoinE2ETest { friend, DirectP2pAuthMode.OFFLINE, ).getOrNull()!! - assertEquals( - FriendActivityKind.HOSTING_WORLD, + val activityResult = activityTarget.use { client.activity( it, com.minekube.connect.share.friend .FriendActivityRequest(UUID.randomUUID()), - ).getOrNull()?.kind - }, + ) + } + assertEquals( + FriendActivityKind.HOSTING_WORLD, + activityResult.getOrNull()?.kind + ?: fail(activityResult.leftOrNull()?.safeMessage + ?: "Host returned no friend activity"), ) // Status and gameplay require different one-shot proxies. 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/ui/FriendsViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendsViewModelTest.kt index 9571308ed..ae367a183 100644 --- 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 @@ -212,6 +212,24 @@ class FriendsViewModelTest { 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() @@ -313,6 +331,51 @@ class FriendsViewModelTest { 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) 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/ShareViewModelTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareViewModelTest.kt index f25f82769..0750e4db5 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -177,6 +178,31 @@ class ShareViewModelTest { 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( @@ -231,6 +257,27 @@ class ShareViewModelTest { 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), @@ -245,6 +292,7 @@ class ShareViewModelTest { answerAdmission: (UUID, Boolean) -> Unit = { _, _ -> }, initialShareWithFriends: Boolean = false, persistShareWithFriends: (Boolean) -> Unit = {}, + persistPresencePrivacy: (PresencePrivacy) -> Unit = {}, startShare: suspend (ShareOptions) -> Either = { options -> @@ -264,6 +312,7 @@ class ShareViewModelTest { initialShareWithFriendsEnabled = initialShareWithFriends, operationDispatcher = operationDispatcher, persistShareWithFriendsEnabled = persistShareWithFriends, + persistPresencePrivacy = persistPresencePrivacy, startShare = startShare, stopShare = { Either.Right(Unit) }, answerAdmission = answerAdmission, 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..ed8606e95 --- /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 = 90L * 1024L * 1024L + check(bytes <= limit) { + "Connect Share Forge 1.20.1 exceeds the 90 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..4aaef2e69 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeConnectShare1201Client.kt @@ -0,0 +1,78 @@ +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.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 { + 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 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, + ) = Unit + + @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/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..5e4d5efa4 --- /dev/null +++ b/share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/Forge1201ArtifactTest.kt @@ -0,0 +1,55 @@ +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")) + 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/neoforge-1.21.1/build.gradle.kts b/share/neoforge-1.21.1/build.gradle.kts new file mode 100644 index 000000000..dd8ac965f --- /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 = 90L * 1024L * 1024L + check(bytes <= limit) { + "Connect Share NeoForge 1.21.1 exceeds the 90 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..03fc86836 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeConnectShare1211Client.kt @@ -0,0 +1,76 @@ +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.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 + +@Mod("connect_share") +class NeoForgeConnectShare1211Client { + private val platform = NeoForgePlatform() + + init { + 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, + ) = Unit + + @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/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..3a680c28e --- /dev/null +++ b/share/neoforge-1.21.1/src/test/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForge1211ArtifactTest.kt @@ -0,0 +1,42 @@ +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("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") + } + } +} From cf195e8fae5aea74a91249bd4aedd0591f0f1072 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 22:08:49 +0200 Subject: [PATCH 141/188] no-mistakes(review): Hardened admission, cleanup, renewal, and loader networking --- .../share/admission/AdmissionController.kt | 62 +++++++- .../admission/AdmissionControllerTest.kt | 64 ++++++++- .../share/fabric/FabricDirectShareIngress.kt | 112 +++++++++++---- .../share/fabric/FabricShareBootstrap.kt | 56 ++++++-- .../share/fabric/FriendRequestServer.kt | 8 +- .../share/fabric/ui/FriendsViewModel.kt | 14 +- .../fabric/FabricDirectShareIngressTest.kt | 35 +++++ .../share/fabric/FriendRequestServerTest.kt | 36 ++++- .../v1_20_1/ForgeConnectShare1201Client.kt | 7 +- .../v1_20_1/ForgeFriendCardNetworking.kt | 121 ++++++++++++++++ .../forge/v1_20_1/Forge1201ArtifactTest.kt | 6 + .../v1_21_1/NeoForgeConnectShare1211Client.kt | 21 ++- .../v1_21_1/NeoForgeFriendCardNetworking.kt | 134 ++++++++++++++++++ .../v1_21_1/NeoForge1211ArtifactTest.kt | 6 + 14 files changed, 626 insertions(+), 56 deletions(-) create mode 100644 share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt create mode 100644 share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt 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 index 8dcaa6da4..9df7af82e 100644 --- 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 @@ -23,7 +23,7 @@ class AdmissionController( ) { private val lock = Any() private val requests = linkedMapOf() - private val authenticatedApprovals = mutableSetOf() + private val authenticatedApprovals = mutableSetOf() private val preapprovedJoins = mutableSetOf() private val mutablePending = MutableStateFlow>(emptyList()) @@ -70,7 +70,7 @@ class AdmissionController( if ( purpose == AdmissionPurpose.JOIN && identity is AdmissionIdentity.Authenticated && - identity.uuid in authenticatedApprovals + authenticatedApprovals.any { it.matches(identity) } ) { return@synchronized RequestLookup.Immediate(AdmissionAnswer.ALLOW) } @@ -119,7 +119,11 @@ class AdmissionController( ) { val identity = entry.value.pending.identity if (identity is AdmissionIdentity.Authenticated) { - authenticatedApprovals += identity.uuid + authenticatedApprovals += AuthenticatedApproval( + uuid = identity.uuid, + directPeerId = identity.directPeerId, + ingress = identity.ingress, + ) } } publishPending() @@ -146,6 +150,31 @@ class AdmissionController( return denied.size } + fun revokeDirectPeer( + peerId: String, + minecraftUuid: UUID? = null, + ): Int { + val revoked = synchronized(lock) { + preapprovedJoins.removeIf { it.directPeerId == peerId } + authenticatedApprovals.removeIf { + it.directPeerId == peerId || + ( + it.directPeerId == null && + minecraftUuid != null && + it.uuid == minecraftUuid + ) + } + val matches = requests.entries.filter { entry -> + entry.value.pending.identity.directPeerId == peerId + } + 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() @@ -250,8 +279,31 @@ class AdmissionController( val minecraftUuid: UUID, ) { fun matches(identity: AdmissionIdentity): Boolean = - (directPeerId != null && directPeerId == identity.directPeerId) || - minecraftUuid == identity.uuid + 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( 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 index e0acd2b17..8556772b2 100644 --- 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 @@ -281,7 +281,31 @@ class AdmissionControllerTest { } @Test - fun `approved friend request also authorizes Connect fallback by player UUID`() = runTest { + 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, @@ -293,10 +317,44 @@ class AdmissionControllerTest { assertEquals( AdmissionAnswer.ALLOW, controller.request( - authenticated("RoboFlax2", AUTHENTICATED_UUID), + requestedIdentity.copy( + connectionId = "connect-gameplay", + directPeerId = null, + ingress = Ingress.CONNECT, + ), ), ) - assertTrue(controller.pending.value.isEmpty()) + } + + @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()) } private fun kotlinx.coroutines.test.TestScope.controller( 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 index 2cda42bfb..dfbfd1d77 100644 --- 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 @@ -22,6 +22,15 @@ import java.nio.file.Path import java.time.Instant import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean +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, @@ -30,6 +39,7 @@ class FabricDirectShareIngress private constructor( private val displayName: () -> String, private val localSocket: (SocketAddress, DirectP2pSession) -> Socket, private val closeNodeOnHandleClose: Boolean, + private val renewalDispatcher: kotlinx.coroutines.CoroutineDispatcher, ) : DirectShareIngress { constructor( dataDirectory: Path, @@ -47,6 +57,7 @@ class FabricDirectShareIngress private constructor( displayName = displayName, localSocket = ::openTaggedLoopbackSocket, closeNodeOnHandleClose = true, + renewalDispatcher = kotlinx.coroutines.Dispatchers.IO, ) internal constructor( @@ -62,6 +73,7 @@ class FabricDirectShareIngress private constructor( displayName = displayName, localSocket = ::openTaggedLoopbackSocket, closeNodeOnHandleClose = false, + renewalDispatcher = kotlinx.coroutines.Dispatchers.IO, ) override suspend fun start( @@ -90,30 +102,36 @@ class FabricDirectShareIngress private constructor( } else { emptyList() } - val payload = ShareInvitePayload( - wireVersion = ShareInviteCodec.WIRE_VERSION, + val invitation = invitation( + node = node, + host = host, shareId = id, - expiresAtEpochMillis = now() - .plusSeconds(INVITATION_LIFETIME_SECONDS) - .toEpochMilli(), + secret = secret, connectAddress = connectAddress, - peerId = host.peerId(), - internetDirectEnabled = options.allowInternetDirect, - directCandidates = internetCandidates, - capability = secret, - ) - val unsigned = ShareInviteCodec.unsignedBytes( - payload, - host.publicKey(), - ) - val invitation = ShareInviteCodec.encode( - SignedShareInvite( - payload = payload, - publicKey = host.publicKey(), - signature = node.sign(unsigned), - ), + options = options, ) node.publish(invitation) + val renewalScope = CoroutineScope(SupervisorJob() + renewalDispatcher) + val renewalJob = renewalScope.launch { + while (isActive) { + delay(INVITATION_RENEWAL_MILLIS) + try { + node.publish( + invitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options, + ), + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: RuntimeException) { + } + } + } val closed = AtomicBoolean() return DirectShareHandle( invitation = invitation, @@ -122,11 +140,12 @@ class FabricDirectShareIngress private constructor( options.allowInternetDirect && internetCandidates.isNotEmpty(), close = { - if ( - closeNodeOnHandleClose && - closed.compareAndSet(false, true) - ) { - node.close() + if (closed.compareAndSet(false, true)) { + renewalJob.cancelAndJoin() + renewalScope.cancel() + if (closeNodeOnHandleClose) { + node.close() + } } }, ) @@ -144,6 +163,45 @@ class FabricDirectShareIngress private constructor( } } + private fun invitation( + 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, @@ -152,6 +210,8 @@ class FabricDirectShareIngress private constructor( capability: () -> String, displayName: () -> String, localSocket: (SocketAddress, DirectP2pSession) -> Socket, + renewalDispatcher: kotlinx.coroutines.CoroutineDispatcher = + kotlinx.coroutines.Dispatchers.IO, ) = FabricDirectShareIngress( nodeFactory = nodeFactory, now = now, @@ -164,6 +224,7 @@ class FabricDirectShareIngress private constructor( displayName = displayName, localSocket = localSocket, closeNodeOnHandleClose = true, + renewalDispatcher = renewalDispatcher, ) private fun openTaggedLoopbackSocket( @@ -202,6 +263,7 @@ class FabricDirectShareIngress private constructor( 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 } } 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 index 7b7985a2d..adc13d1e8 100644 --- 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 @@ -31,9 +31,11 @@ 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 @@ -137,6 +139,8 @@ object FabricShareBootstrap { ) val gateway = ShareConnectionGateway.bind(friendRequestServer) var browser: FabricShareBrowser? = null + var controlPlane: ConnectControlPlane? = null + var directControlPlane: DirectControlPlane? = null try { val directPeer = FabricDirectPeerRuntime( dataDirectory = dataDirectory, @@ -180,14 +184,16 @@ object FabricShareBootstrap { directIngress = directIngress, failureReporter = logger::warn, ) - val controlPlane = ConnectControlPlane( + val startedControlPlane = ConnectControlPlane( scope = scope, ingress = ingress, identity = identityStore::currentOrCreate, target = gateway.serverSocketAddress, failureReporter = logger::warn, - ).also(ConnectControlPlane::start) - val directControlPlane = DirectControlPlane( + ) + controlPlane = startedControlPlane + startedControlPlane.start() + val startedDirectControlPlane = DirectControlPlane( scope = scope, ingress = directIngress, options = ShareOptions( @@ -198,7 +204,9 @@ object FabricShareBootstrap { target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, failureReporter = logger::warn, - ).also(DirectControlPlane::start) + ) + directControlPlane = startedDirectControlPlane + startedDirectControlPlane.start() val viewModel = ShareViewModel( scope = scope, shareState = coordinator.state, @@ -228,7 +236,7 @@ object FabricShareBootstrap { "${identityStore.currentOrCreate().endpoint}" + ".play.minekube.net", ) - controlPlane.restart() + startedControlPlane.restart() }, startShare = coordinator::start, stopShare = coordinator::stop, @@ -263,11 +271,21 @@ object FabricShareBootstrap { }, ) } - val friendsViewModel = FriendsViewModel(friendStore) { - scope.launch(Dispatchers.IO) { - removalSync.sync() - } - } + val friendsViewModel = FriendsViewModel( + store = friendStore, + onPeerRemoved = { peerId -> + val minecraftUuid = friendStore.pendingRemovals() + .lastOrNull { it.friend.peerId == peerId } + ?.friend + ?.minecraftUuid + admission.revokeDirectPeer(peerId, minecraftUuid) + }, + onRemovalQueued = { + scope.launch(Dispatchers.IO) { + removalSync.sync() + } + }, + ) val activityMonitor = FriendActivityMonitor( store = friendStore, query = { friend -> @@ -329,8 +347,8 @@ object FabricShareBootstrap { minecraftVersion = minecraftVersion, modVersion = modVersion, approvedJoins = approvedJoins, - controlPlane = controlPlane, - directControlPlane = directControlPlane, + controlPlane = startedControlPlane, + directControlPlane = startedDirectControlPlane, browser = activeBrowser, friendActivity = activityMonitor, gateway = gateway, @@ -339,8 +357,18 @@ object FabricShareBootstrap { guestScreens = guestScreens, ) } catch (failure: Throwable) { - browser?.close() - gateway.close() + try { + withContext(NonCancellable) { + directControlPlane?.shutdown() + controlPlane?.shutdown() + browser?.close() + gateway.close() + } + } catch (cleanupFailure: Throwable) { + if (cleanupFailure !== failure) { + failure.addSuppressed(cleanupFailure) + } + } throw failure } } 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 index 14acae935..708cf3483 100644 --- 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 @@ -79,10 +79,10 @@ class FriendRequestServer( ) { FriendControlResponse.Invalid } else { - admission.denyDirectPeer( - peerId, - AdmissionPurpose.FRIEND, - ) + val minecraftUuid = friendStore.relationship(peerId) + .getOrNull() + ?.minecraftUuid + admission.revokeDirectPeer(peerId, minecraftUuid) if (friendStore.applyRemoteRemoval(peerId)) { notifyRelationshipChanged() } 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 index ae282ce67..10b20d92d 100644 --- 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 @@ -74,6 +74,7 @@ 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() @@ -145,6 +146,7 @@ class FriendsViewModel( ifRight = { removed -> refresh() if (removed) { + notifyPeerRemoved(peerId) onRemovalQueued() } removed @@ -159,7 +161,10 @@ class FriendsViewModel( }, ifRight = { blocked -> refresh() - if (blocked) onRemovalQueued() + if (blocked) { + notifyPeerRemoved(peerId) + onRemovalQueued() + } blocked }, ) @@ -353,6 +358,13 @@ class FriendsViewModel( mutableState.value = mutableState.value.transform() } + private fun notifyPeerRemoved(peerId: String) { + try { + onPeerRemoved(peerId) + } catch (_: RuntimeException) { + } + } + private fun SavedFriend.summary(): FriendSummary { val remote = remotePresence[peerId] ?.takeIf { it.online } 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 index 32c4c68ea..33201f914 100644 --- 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 @@ -20,9 +20,13 @@ 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 @@ -103,6 +107,35 @@ class FabricDirectShareIngressTest { handle.close() } + @Test + fun `persistent direct host republishes before its invitation expires`() = 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") }, + renewalDispatcher = StandardTestDispatcher(testScheduler), + ) + + val handle = ingress.start( + OPTIONS, + InetSocketAddress( + java.net.InetAddress.getLoopbackAddress(), + 25_565, + ), + null, + ) + runCurrent() + advanceTimeBy(12 * 60 * 60 * 1_000L) + runCurrent() + + assertTrue(node.publishedInvitations.size >= 2) + handle.close() + } + @Test fun `partial startup closes the isolated node`() = runTest { val node = FakeDirectNode(failPublish = true) @@ -181,6 +214,7 @@ class FabricDirectShareIngressTest { ), ) var published: String? = null + val publishedInvitations = mutableListOf() var closed = false override fun startHost( @@ -200,6 +234,7 @@ class FabricDirectShareIngressTest { error("publish failed") } published = invitation + publishedInvitations += invitation } override fun close() { 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 index a72eb6767..1210f739e 100644 --- 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 @@ -1,6 +1,9 @@ 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 @@ -25,6 +28,7 @@ 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 @@ -193,9 +197,32 @@ class FriendRequestServerTest { .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 server = FriendRequestServer( scope = backgroundScope, - admission = admission(), + admission = admission, issuer = issuer("host"), receiver = FriendCardReceiver(hostStore), friendStore = hostStore, @@ -218,6 +245,13 @@ class FriendRequestServerTest { ) assertTrue(hostStore.all().isEmpty()) assertTrue(hostStore.pendingRemovals().isEmpty()) + val afterRemoval = async { + admission.request(authenticated) + } + runCurrent() + assertEquals(1, admission.pending.value.size) + admission.resetShare() + assertEquals(AdmissionAnswer.STOPPED, afterRemoval.await()) } @Test 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 index 4aaef2e69..fa10c48c8 100644 --- 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 @@ -60,7 +60,12 @@ class ForgeConnectShare1201Client { issuer: FriendCardIssuer, receiver: FriendCardReceiver, approvedJoins: ApprovedJoinTracker, - ) = Unit + ) = ForgeFriendCardNetworking.install( + scope, + issuer, + receiver, + approvedJoins, + ) @SubscribeEvent fun onClientTick(event: TickEvent.ClientTickEvent) { 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..2048c9670 --- /dev/null +++ b/share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeFriendCardNetworking.kt @@ -0,0 +1,121 @@ +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.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) } + .decoder { buffer -> FriendCardMessage(buffer.readUtf(MAX_CARD_CHARS)) } + .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, + ) ?: return@consumerMainThread + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.receive( + invitation = message.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + .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)) + 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, + ) + + private data object FriendCardRequestMessage +} 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 index 5e4d5efa4..ca22786c7 100644 --- 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 @@ -32,6 +32,12 @@ class Forge1201ArtifactTest { ) 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"), 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 index 03fc86836..aa0410c54 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -18,12 +19,18 @@ 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 { +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) } @@ -60,7 +67,17 @@ class NeoForgeConnectShare1211Client { issuer: FriendCardIssuer, receiver: FriendCardReceiver, approvedJoins: ApprovedJoinTracker, - ) = Unit + ) = 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) { 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..5a2de7f24 --- /dev/null +++ b/share/neoforge-1.21.1/src/main/kotlin/com/minekube/connect/share/neoforge/v1_21_1/NeoForgeFriendCardNetworking.kt @@ -0,0 +1,134 @@ +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.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), + ) + 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, + ) ?: return@playToServer + handlers.scope.launch(Dispatchers.IO) { + handlers.receiver.receive( + invitation = payload.invitation, + displayName = player.gameProfile.name, + authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, + allowAutomaticJoin = true, + ) + } + } + } + + 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, +) : 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 -> FriendCardPayload(buffer.readUtf(MAX_CARD_CHARS)) }, + ) + } +} + +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/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 index 3a680c28e..1c996f580 100644 --- 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 @@ -22,6 +22,12 @@ class NeoForge1211ArtifactTest { 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/") }) From 9ebbcee2babf542f14e8b1e8b7694dbbffe8b582 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 23:00:30 +0200 Subject: [PATCH 142/188] no-mistakes(document): Corrected Share matrix and Java toolchain docs --- docs/connect-share-testing.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 12b65c5e3..8575685ab 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,10 +1,11 @@ # Connect Share acceptance -Connect Share is built separately for Fabric 1.20.1, 1.21.1, and 1.21.11, -Forge 1.20.1, and NeoForge 1.21.1 on a Java 21 build 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. +Connect Share is built separately for Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2, +Forge 1.20.1, and NeoForge 1.21.1 on their respective Java toolchains. 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. From c5cf91ea3e1fc4376d8f20f9b9a0130b6382cdd5 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sat, 1 Aug 2026 23:10:16 +0200 Subject: [PATCH 143/188] no-mistakes: apply CI fixes --- .github/workflows/pullrequest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index f5e0d55d0..a28a065ab 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -73,11 +73,11 @@ jobs: loader: Fabric artifact: connect-share-fabric-1.21.1-*.jar - minecraft: 1.20.1 - project: forge-1.20.1 + project: forge-1-20-1 loader: Forge artifact: connect-share-forge-1.20.1-*.jar - minecraft: 1.21.1 - project: neoforge-1.21.1 + project: neoforge-1-21-1 loader: NeoForge artifact: connect-share-neoforge-1.21.1-*.jar From e340ae03795be9dc0b6c2512e7deac59bf9d4b73 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 1 Aug 2026 23:32:37 +0200 Subject: [PATCH 144/188] fix(share): close social authorization gaps --- README.md | 3 +- docs/connect-share-testing.md | 12 +++++++ docs/connect-share.md | 14 ++++++-- share/AGENTS.md | 7 ++++ .../connect/share/DirectShareIngress.kt | 22 ++++++++++-- .../connect/share/ShareCoordinator.kt | 6 +++- .../share/admission/AdmissionController.kt | 8 ++++- .../connect/share/friend/FriendStore.kt | 34 +++++++++++++++++-- .../connect/share/ShareCoordinatorTest.kt | 21 ++++++++++++ .../admission/AdmissionControllerTest.kt | 20 +++++++++++ .../connect/share/friend/FriendStoreTest.kt | 25 ++++++++++++-- .../share/fabric/v1_20_1/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../share/fabric/v1_21_1/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../fabric/v1_21_11/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../share/fabric/v26_2/ShareStatusScreen.kt | 4 +-- .../assets/connect-share/lang/de_de.json | 2 +- .../assets/connect-share/lang/en_us.json | 2 +- .../share/fabric/ApprovedJoinTracker.kt | 18 ++++++++++ .../share/fabric/FabricDirectShareIngress.kt | 22 ++++++------ .../share/fabric/FabricShareBootstrap.kt | 21 +++++++++++- .../share/fabric/FabricShareBrowser.kt | 33 ++++++++++++------ .../connect/share/fabric/FriendCardIssuer.kt | 12 +++++-- .../share/fabric/FriendRequestServer.kt | 2 ++ .../share/fabric/PersistentDirectIngress.kt | 22 ++++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 3 ++ .../share/fabric/ApprovedJoinTrackerTest.kt | 17 ++++++++++ .../fabric/FabricDirectShareIngressTest.kt | 7 +++- .../share/fabric/FabricShareBrowserTest.kt | 20 +++++++++++ .../share/fabric/FriendCardIssuerTest.kt | 34 +++++++++++++++++++ .../share/fabric/FriendRequestServerTest.kt | 4 +++ 36 files changed, 366 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index b48ef0eb5..596552073 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ The current implementation provides: - a stable `*.play.minekube.net` address for unmodified Java clients; - signed friend links and temporary world invitations for modded clients; - automatic same-LAN discovery and direct libp2p transport; -- optional internet-direct attempts only when host and guest both opt in; +- direct libp2p friend delivery across LANs from explicitly shared friend links, + plus opt-in internet-direct gameplay attempts; - exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; - explicit support for authenticated and unverified offline-mode guests; and diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 8575685ab..2d1e4a592 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -98,6 +98,11 @@ address, such as a publicly routed host or an explicitly configured network. 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. Copying a friend link is an +explicit disclosure action and may include signed direct candidates. A saved +friend tries fresh mDNS first, then those 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 internet-direct disabled on either peer, confirm the guest does not @@ -115,6 +120,13 @@ self-hosted libp2p relay. 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 diff --git a/docs/connect-share.md b/docs/connect-share.md index 46db3a199..539e5f2b4 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -19,6 +19,12 @@ IP addresses or create a new link for every world. requests and presence themselves are authenticated libp2p traffic and never use Connect as a social relay. +Friend links carry signed direct candidates when the local libp2p 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. 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 @@ -47,9 +53,11 @@ or blocking cannot be bypassed with an old attempt. - 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 is opt-in on both sides because it can reveal public IP - addresses to that friend. Direct LAN addresses, endpoint tokens, invitation - capabilities, and private keys are never shown in the social UI. +- Internet-direct gameplay remains opt-in on both sides. 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. diff --git a/share/AGENTS.md b/share/AGENTS.md index 9c190ad75..23afe60f5 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -99,6 +99,13 @@ redesigned for Kotlin. authentication. Otherwise an offline Prism friend is rejected as "Invalid session" before admission runs. `ONLINE` direct sessions must never silently downgrade. +- Persistent friend cards must retain signed direct candidates and friend + control must try those candidates after mDNS, without ever using Connect as a + social relay. Copying a friend link is the disclosure boundary for those + routes; removal must revoke both admission grants and reciprocal-card proofs. +- Invitation renewal is not complete when only mDNS receives a fresh token. + Every copy action must resolve the current handle invitation so a long-running + share never copies the original expired token. - For no-click friend-request E2E, temporarily enable automatic joins only for the confirmed test friend, send the real libp2p join request, and restore the permission afterwards. Keep machine-specific instance paths and credentials 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 index 1cb010ba1..81e3f8d9b 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/DirectShareIngress.kt @@ -3,18 +3,34 @@ package com.minekube.connect.share import java.net.SocketAddress class DirectShareHandle( - val invitation: String, + 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 = this.invitation, + invitation: String? = null, lanAvailable: Boolean = this.lanAvailable, internetAvailable: Boolean = this.internetAvailable, close: suspend () -> Unit = this.close, ) = DirectShareHandle( - invitation = invitation, + invitationProvider = invitation?.let { value -> { value } } + ?: invitationProvider, lanAvailable = lanAvailable, internetAvailable = internetAvailable, close = close, 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 index 0e5711200..4c358ec58 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareCoordinator.kt @@ -28,6 +28,7 @@ class ShareCoordinator( ) { private val lifecycleMutex = Mutex() private val mutableState = MutableStateFlow(ShareState.Idle) + @Volatile private var active: ActiveShare? = null val state: StateFlow = mutableState.asStateFlow() @@ -102,7 +103,7 @@ class ShareCoordinator( internetDirectAvailable = acquired.direct?.internetAvailable == true, ) - active = ActiveShare(release) + active = ActiveShare(release, acquired.direct) mutableState.value = sharing Either.Right(sharing) } catch (cancellation: CancellationException) { @@ -162,6 +163,8 @@ class ShareCoordinator( suspend fun worldReplaced(): Either = stop() + fun currentInvitation(): String? = active?.direct?.invitation + private data class AcquiredShare( val target: LocalShareTarget, val connect: ConnectShareHandle?, @@ -170,6 +173,7 @@ class ShareCoordinator( private data class ActiveShare( val release: suspend (ExitCase) -> Unit, + val direct: DirectShareHandle?, ) @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) 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 index 9df7af82e..e8db30569 100644 --- 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 @@ -165,7 +165,13 @@ class AdmissionController( ) } val matches = requests.entries.filter { entry -> - entry.value.pending.identity.directPeerId == peerId + 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() 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 index 72b66deb9..d7bb350f8 100644 --- 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 @@ -68,6 +68,8 @@ data class SavedFriend( val shareId: UUID, val capability: String, val connectAddress: String?, + val internetDirectEnabled: Boolean = false, + val directCandidates: List = emptyList(), val displayName: String, val minecraftUuid: UUID? = null, val permissions: FriendPermissions = FriendPermissions(), @@ -77,7 +79,8 @@ data class SavedFriend( override fun toString(): String = "SavedFriend(peerId=$peerId, publicKey=, " + "shareId=$shareId, capability=, " + - "connectAddress=$connectAddress, displayName=$displayName, " + + "connectAddress=$connectAddress, directCandidates=, " + + "displayName=$displayName, " + "minecraftUuid=$minecraftUuid, permissions=$permissions, " + "relationshipStatus=$relationshipStatus)" } @@ -249,6 +252,8 @@ class FriendStore( shareId = invite.payload.shareId, capability = invite.payload.capability, connectAddress = invite.payload.connectAddress, + internetDirectEnabled = invite.payload.internetDirectEnabled, + directCandidates = invite.payload.directCandidates, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = (existing?.permissions ?: FriendPermissions()) @@ -494,6 +499,11 @@ class FriendStore( val shareId = UUID.fromString(json.requiredString("shareId")) val capability = json.requiredString("capability") val connectAddress = json.optionalString("connectAddress") + val internetDirectEnabled = + json.optionalBoolean("internetDirectEnabled") ?: false + val directCandidates = json.getAsJsonArray("directCandidates") + ?.map { it.asString } + ?: emptyList() val displayName = json.requiredString("displayName") val minecraftUuid = json.optionalString("minecraftUuid") ?.let(UUID::fromString) @@ -501,6 +511,15 @@ class FriendStore( 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") @@ -534,6 +553,8 @@ class FriendStore( shareId = shareId, capability = capability, connectAddress = connectAddress, + internetDirectEnabled = internetDirectEnabled, + directCandidates = directCandidates, displayName = displayName, minecraftUuid = minecraftUuid, permissions = parsedPermissions, @@ -602,6 +623,10 @@ class FriendStore( addProperty("shareId", shareId.toString()) addProperty("capability", capability) connectAddress?.let { addProperty("connectAddress", it) } + addProperty("internetDirectEnabled", internetDirectEnabled) + add("directCandidates", JsonArray().apply { + directCandidates.forEach(::add) + }) addProperty("displayName", displayName) minecraftUuid?.let { addProperty("minecraftUuid", it.toString()) } addProperty("relationshipStatus", relationshipStatus.name) @@ -667,14 +692,19 @@ class FriendStore( 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 = 4 + private const val WIRE_VERSION = 5 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() 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 index 90ec30ea3..daa158288 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareCoordinatorTest.kt @@ -133,6 +133,27 @@ class ShareCoordinatorTest { ) } + @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() 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 index 8556772b2..9a5061413 100644 --- 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 @@ -357,6 +357,26 @@ class AdmissionControllerTest { 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 }, 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 index 91d69bba2..cdd68dca4 100644 --- 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 @@ -59,6 +59,23 @@ class FriendStoreTest { ) } + @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 `confirming an outgoing request promotes it across restarts`() { val store = FriendStore(tempDir) @@ -325,6 +342,8 @@ class FriendStoreTest { private fun signedLink( expiresAt: Instant = NOW.plusSeconds(3_600), + internetDirectEnabled: Boolean = false, + directCandidates: List = emptyList(), ): String { val payload = ShareInvitePayload( wireVersion = ShareInviteCodec.WIRE_VERSION, @@ -332,8 +351,8 @@ class FriendStoreTest { expiresAtEpochMillis = expiresAt.toEpochMilli(), connectAddress = CONNECT_ADDRESS, peerId = PEER_ID, - internetDirectEnabled = false, - directCandidates = emptyList(), + internetDirectEnabled = internetDirectEnabled, + directCandidates = directCandidates, capability = CAPABILITY, ) val unsigned = ShareInviteCodec.unsignedBytes( @@ -383,6 +402,8 @@ class FriendStoreTest { 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/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 index cc1b4b760..c27494d70 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft!!.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index 7ac012a5b..3e7b03c02 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft!!.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index fa4f627ae..8f479f162 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index a86bc984c..032ce83b8 100644 --- 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 @@ -43,12 +43,12 @@ class ShareStatusScreen( Button.builder( Component.translatable("connect_share.status.copy_invitation"), ) { - sharing?.invitation?.let( + viewModel.currentInvitation()?.let( minecraft.keyboardHandler::setClipboard, ) }.bounds(width / 2 - 155, 50, 150, 20).build(), ) - copyInvitation.active = sharing?.invitation != null + copyInvitation.active = viewModel.currentInvitation() != null val copyAddress = addRenderableWidget( Button.builder( Component.translatable("connect_share.status.copy_address"), 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 index 35817fbb1..cf0166279 100644 --- 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 @@ -48,7 +48,7 @@ "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": "Teile deinen dauerhaften Freundeslink aus jedem Menü. Es muss keine Welt geöffnet sein.", + "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", 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 index 2d38a7018..ece7e5c99 100644 --- 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 @@ -48,7 +48,7 @@ "connect_share.friends.page": "Page %s of %s", "connect_share.friends.description": "Friends appear here when they're ready to play.", "connect_share.friends.copy_my_link": "Copy my friend link", - "connect_share.friends.copy_my_link.tooltip": "Share your stable friend link from any menu. No world needs to be open.", + "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", 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 index 6e405f5e8..fd490811f 100644 --- 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 @@ -34,6 +34,7 @@ class ApprovedJoinTracker( authenticatedMinecraftUuid = (identity as? AdmissionIdentity.Authenticated)?.uuid, ), + directPeerId = identity.directPeerId, approvedAtMillis = now, ) } @@ -67,6 +68,22 @@ class ApprovedJoinTracker( } } + 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) @@ -77,6 +94,7 @@ class ApprovedJoinTracker( private data class TimedProof( val proof: ApprovedJoinProof, + val directPeerId: String?, val approvedAtMillis: Long, ) 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 index dfbfd1d77..c4c79e9ca 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -110,22 +111,23 @@ class FabricDirectShareIngress private constructor( connectAddress = connectAddress, options = options, ) + val currentInvitation = AtomicReference(invitation) node.publish(invitation) val renewalScope = CoroutineScope(SupervisorJob() + renewalDispatcher) val renewalJob = renewalScope.launch { while (isActive) { delay(INVITATION_RENEWAL_MILLIS) try { - node.publish( - invitation( - node = node, - host = host, - shareId = id, - secret = secret, - connectAddress = connectAddress, - options = options, - ), + val renewed = invitation( + node = node, + host = host, + shareId = id, + secret = secret, + connectAddress = connectAddress, + options = options, ) + node.publish(renewed) + currentInvitation.set(renewed) } catch (cancellation: CancellationException) { throw cancellation } catch (_: RuntimeException) { @@ -134,7 +136,7 @@ class FabricDirectShareIngress private constructor( } val closed = AtomicBoolean() return DirectShareHandle( - invitation = invitation, + invitationProvider = currentInvitation::get, lanAvailable = true, internetAvailable = options.allowInternetDirect && 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 index adc13d1e8..3e819f039 100644 --- 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 @@ -9,6 +9,7 @@ import com.minekube.connect.share.ShareOptions 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 @@ -120,11 +121,25 @@ object FabricShareBootstrap { "${endpointIdentity.endpoint}.play.minekube.net", ) val accessIdentityStore = ShareAccessIdentityStore(dataDirectory) + val directIngressReference = AtomicReference() val friendCardIssuer = FriendCardIssuer( dataDirectory = dataDirectory, displayName = playerDisplayName, connectAddress = { ownConnectAddress.get() }, accessIdentityStore = accessIdentityStore, + 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( @@ -133,6 +148,7 @@ object FabricShareBootstrap { issuer = friendCardIssuer, receiver = friendCardReceiver, friendStore = friendStore, + approvedJoins = approvedJoins, activity = friendActivity, presencePrivacy = { preferences.get().presence }, joinTarget = friendJoinTarget, @@ -176,6 +192,7 @@ object FabricShareBootstrap { val directIngress = PersistentDirectIngress( directPeer.ingress, ) + directIngressReference.set(directIngress) val coordinator = ShareCoordinator( bridge = bridge, ingress = ingress, @@ -199,7 +216,7 @@ object FabricShareBootstrap { options = ShareOptions( gameMode = ShareGameMode.SURVIVAL, allowCheats = false, - allowInternetDirect = false, + allowInternetDirect = true, ), target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, @@ -241,6 +258,7 @@ object FabricShareBootstrap { startShare = coordinator::start, stopShare = coordinator::stop, answerAdmission = admission::answer, + currentInvitation = coordinator::currentInvitation, ) viewModelReference.set(viewModel) val runtime = ConnectShareRuntime( @@ -279,6 +297,7 @@ object FabricShareBootstrap { ?.friend ?.minecraftUuid admission.revokeDirectPeer(peerId, minecraftUuid) + approvedJoins.revokeDirectPeer(peerId, minecraftUuid) }, onRemovalQueued = { scope.launch(Dispatchers.IO) { 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 index f118e17b5..9be929bea 100644 --- 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 @@ -269,16 +269,29 @@ class FabricShareBrowser private constructor( authMode: DirectP2pAuthMode, ): Either = withContext(ioDispatcher) { - val discovered = matchingLanShare(friend) - ?: return@withContext GuestJoinFailure.NoRoute.left() - openDirect( - route = ShareRoute.DIRECT_LAN, - address = discovered.lanAddress, - shareId = friend.shareId.toString(), - capability = friend.capability, - authMode = authMode, - timeout = LAN_TIMEOUT, - )?.right() ?: GuestJoinFailure.NoRoute.left() + 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) { + 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( 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 index 673ca597c..bc714e38c 100644 --- 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 @@ -18,6 +18,11 @@ import java.util.UUID data object FriendCardIssueFailure +data class FriendDirectRoute( + val internetDirectEnabled: Boolean, + val candidates: List, +) + class FriendCardReceiver( private val store: FriendStore, ) { @@ -52,6 +57,7 @@ class FriendCardIssuer( private val displayName: () -> String? = { null }, private val accessIdentityStore: ShareAccessIdentityStore = ShareAccessIdentityStore(dataDirectory), + private val directRoute: suspend () -> FriendDirectRoute? = { null }, private val connectAddress: suspend () -> String?, ) { suspend fun issue( @@ -69,6 +75,7 @@ class FriendCardIssuer( DirectP2pNode( dataDirectory.resolve(IDENTITY_FILE_NAME), ).use { node -> + val route = directRoute() val payload = ShareInvitePayload( wireVersion = ShareInviteCodec.WIRE_VERSION, shareId = access.shareId, @@ -77,8 +84,9 @@ class FriendCardIssuer( .toEpochMilli(), connectAddress = connectAddress(), peerId = node.peerId(), - internetDirectEnabled = false, - directCandidates = emptyList(), + internetDirectEnabled = + route?.internetDirectEnabled == true, + directCandidates = route?.candidates.orEmpty(), capability = access.capability, displayName = normalizedDisplayName, ) 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 index 708cf3483..83714465f 100644 --- 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 @@ -36,6 +36,7 @@ class FriendRequestServer( 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 = {}, @@ -83,6 +84,7 @@ class FriendRequestServer( .getOrNull() ?.minecraftUuid admission.revokeDirectPeer(peerId, minecraftUuid) + approvedJoins?.revokeDirectPeer(peerId, minecraftUuid) if (friendStore.applyRemoteRemoval(peerId)) { notifyRelationshipChanged() } 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 index 0a9e4a3ab..3ca607b8c 100644 --- 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 @@ -11,8 +11,12 @@ 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 @@ -57,6 +61,24 @@ class PersistentDirectIngress( 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, 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 index 75c775e09..5fc38a7ca 100644 --- 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 @@ -124,8 +124,11 @@ class ShareViewModel( 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, 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 index 5802dee98..3cf1ee680 100644 --- 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 @@ -53,6 +53,23 @@ class ApprovedJoinTrackerTest { assertNull(tracker.consume("Robin", PLAYER_UUID)) } + @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)) + } + 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/FabricDirectShareIngressTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FabricDirectShareIngressTest.kt index 33201f914..c35608aca 100644 --- 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 @@ -110,9 +110,10 @@ class FabricDirectShareIngressTest { @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 = { Instant.ofEpochMilli(NOW) }, + now = { currentTime }, shareId = { SHARE_ID }, capability = { CAPABILITY }, displayName = { "World" }, @@ -128,11 +129,15 @@ class FabricDirectShareIngressTest { ), 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() } 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 index 5f65efb48..caaad8476 100644 --- 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 @@ -144,6 +144,24 @@ class FabricShareBrowserTest { 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 ignores LAN metadata signed by a different identity`() = runTest { val node = FakeGuestNode() @@ -385,6 +403,8 @@ class FabricShareBrowserTest { shareId = invitation.payload.shareId, capability = invitation.payload.capability, connectAddress = invitation.payload.connectAddress, + internetDirectEnabled = invitation.payload.internetDirectEnabled, + directCandidates = invitation.payload.directCandidates, displayName = "Robin", ) } 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 index 92eccd392..3fbf9ae27 100644 --- 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 @@ -139,7 +139,41 @@ class FriendCardIssuerTest { ) } + @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/FriendRequestServerTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/FriendRequestServerTest.kt index 1210f739e..b89caca2c 100644 --- 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 @@ -220,12 +220,15 @@ class FriendRequestServerTest { 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), ) @@ -245,6 +248,7 @@ class FriendRequestServerTest { ) assertTrue(hostStore.all().isEmpty()) assertTrue(hostStore.pendingRemovals().isEmpty()) + assertFalse(approvedJoins.hasProof("bob", PLAYER_UUID)) val afterRemoval = async { admission.request(authenticated) } From a20b6f0f4cb76cce2f86aaeb6911412b4f11317c Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 00:10:12 +0200 Subject: [PATCH 145/188] no-mistakes(review): Closed direct-route, privacy, convergence, and packaging findings --- .github/workflows/connect-share-release.yml | 2 +- .github/workflows/pullrequest.yml | 16 +++-- .../connect/tunnel/p2p/DirectP2pNode.java | 15 +++++ .../tunnel/p2p/DirectP2pNodeRuntime.java | 8 ++- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 8 ++- .../connect/share/friend/FriendControlWire.kt | 12 ++++ .../connect/share/friend/FriendStore.kt | 37 ++++++++-- .../connect/share/friend/FriendStoreTest.kt | 25 ++++++- .../fabric/v1_20_1/ConnectShare12111Client.kt | 2 +- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 13 ++++ .../fabric/v1_21_1/ConnectShare12111Client.kt | 2 +- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 13 ++++ .../v1_21_11/ConnectShare12111Client.kt | 2 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 13 ++++ .../fabric/v26_2/ConnectShare262Client.kt | 2 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 13 ++++ .../share/fabric/FabricDirectPeerRuntime.kt | 7 +- .../share/fabric/FabricDirectShareIngress.kt | 44 +++++++++--- .../share/fabric/FabricShareBootstrap.kt | 14 ++-- .../share/fabric/FabricShareBrowser.kt | 67 +++++++++++++++++++ .../connect/share/fabric/FriendCardIssuer.kt | 15 ++++- .../share/fabric/FriendPairingClient.kt | 1 + .../share/fabric/FriendPresenceMonitor.kt | 2 +- .../share/fabric/FriendRequestServer.kt | 21 ++++-- .../share/fabric/MinecraftStatusProbe.kt | 2 + .../share/fabric/ui/FriendsViewModel.kt | 2 + .../fabric/FabricDirectPeerRuntimeTest.kt | 5 +- .../fabric/FabricDirectShareIngressTest.kt | 15 ++++- .../share/fabric/FabricShareBrowserTest.kt | 63 +++++++++++++++-- .../fabric/FriendPairingDirectE2ETest.kt | 12 +++- .../share/fabric/FriendRequestServerTest.kt | 6 +- 31 files changed, 400 insertions(+), 59 deletions(-) diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml index 287460f94..c81d1193b 100644 --- a/.github/workflows/connect-share-release.yml +++ b/.github/workflows/connect-share-release.yml @@ -65,7 +65,7 @@ jobs: set -euo pipefail mkdir -p dist for minecraft in 1.20.1 1.21.1 1.21.11 26.2; do - project="fabric-${minecraft//./-}" + 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' \ diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index a28a065ab..e35c0cf0f 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -66,18 +66,22 @@ jobs: 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 @@ -105,12 +109,12 @@ jobs: with: name: Connect Share ${{ matrix.loader }} ${{ matrix.minecraft }} path: | - share/${{ matrix.project }}/build/libs/${{ matrix.artifact }} - !share/${{ matrix.project }}/build/libs/*-sources.jar - !share/${{ matrix.project }}/build/libs/*-dev-*.jar - !share/${{ matrix.project }}/build/libs/*-dev-shadow.jar - !share/${{ matrix.project }}/build/libs/*-unshaded.jar - !share/${{ matrix.project }}/build/libs/*-parent-shadow.jar + 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 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 index f13ed7146..50ef7f13f 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNode.java @@ -38,6 +38,7 @@ public final class DirectP2pNode implements AutoCloseable { private Method startHost; private Method sign; private Method publish; + private Method publishWithDiscoveryInvitation; private Method inspect; private Method startDiscovery; private Method openProxy; @@ -74,6 +75,10 @@ private void initialize(Path identityFile) { publish = accessible(runtimeClass.getDeclaredMethod( "publish", String.class)); + publishWithDiscoveryInvitation = accessible(runtimeClass.getDeclaredMethod( + "publish", + String.class, + String.class)); inspect = accessible(runtimeClass.getDeclaredMethod( "inspect", String.class, @@ -120,6 +125,16 @@ 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) { 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 index 200a2d1c1..fc73f067a 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -107,6 +107,7 @@ final class DirectP2pNodeRuntime { private DirectP2pHostConfig hostConfig; private DirectP2pHostHandler hostHandler; private volatile String invitation; + private volatile String discoveryInvitation; private JmDNS discovery; private DirectP2pDiscoveryListener discoveryListener; private boolean started; @@ -183,11 +184,16 @@ synchronized byte[] sign(byte[] payload) { } 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(); } @@ -605,7 +611,7 @@ private static String requireInvitation(String value) { } private byte[] encodeInfoResponse() { - String currentInvitation = invitation; + String currentInvitation = discoveryInvitation; DirectP2pHostConfig currentConfig = hostConfig; if (currentInvitation == null || currentConfig == null) { return null; 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 index a389b3ad8..a58f6ab33 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -216,7 +216,9 @@ void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { "Robin's World", false), ignored -> new Socket()); - host.publish("minekube://share/signed-secret-payload"); + host.publish( + "minekube://share/signed-secret-payload", + "minekube://share/signed-lan-payload"); guest = new DirectP2pNode(); DirectP2pDiscoveredShare discovered = guest.inspect( @@ -226,9 +228,9 @@ void publishedHostMetadataCanBeInspectedWithoutAdvertisingItsCapability() { assertEquals("Robin's World", discovered.displayName()); assertEquals(hostInfo.peerId(), discovered.peerId()); assertEquals( - "minekube://share/signed-secret-payload", + "minekube://share/signed-lan-payload", discovered.invitation()); - assertFalse(discovered.toString().contains("signed-secret-payload")); + assertFalse(discovered.toString().contains("signed-lan-payload")); assertFalse(discovered.toString().contains(hostInfo.lanAddresses().get(0))); } 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 index ecc5229e4..c3e049c65 100644 --- 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 @@ -7,12 +7,14 @@ 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) @@ -122,6 +124,8 @@ object FriendControlWire { 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) } @@ -145,6 +149,10 @@ object FriendControlWire { control.readLong(), control.readLong(), ) + val relationshipId = UUID( + control.readLong(), + control.readLong(), + ) val displayName = control .readString(MAX_DISPLAY_NAME_BYTES) .trim() @@ -154,6 +162,7 @@ object FriendControlWire { control.ensureFinished() FriendControlRequest( requestId = requestId, + relationshipId = relationshipId, displayName = displayName, invitation = invitation, ) @@ -166,6 +175,8 @@ object FriendControlWire { writeVarInt(CONTROL_REMOVAL_PACKET_ID) writeLong(request.operationId.mostSignificantBits) writeLong(request.operationId.leastSignificantBits) + writeLong(request.relationshipId.mostSignificantBits) + writeLong(request.relationshipId.leastSignificantBits) } return output.toByteArray() } @@ -181,6 +192,7 @@ object FriendControlWire { ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) val request = FriendRemovalRequest( UUID(control.readLong(), control.readLong()), + UUID(control.readLong(), control.readLong()), ) control.ensureFinished() request 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 index d7bb350f8..8c8ffd336 100644 --- 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 @@ -67,6 +67,7 @@ data class SavedFriend( val publicKeyBase64: String, val shareId: UUID, val capability: String, + val relationshipId: UUID = UUID.randomUUID(), val connectAddress: String?, val internetDirectEnabled: Boolean = false, val directCandidates: List = emptyList(), @@ -168,12 +169,14 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), ): Either = storeInvitation( invitationUri = invitationUri, displayName = displayName, relationshipStatus = FriendRelationshipStatus.CONFIRMED, now = now, + relationshipId = relationshipId, ) @Synchronized @@ -181,6 +184,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), ): Either = storeInvitation( invitationUri = invitationUri, @@ -188,6 +192,7 @@ class FriendStore( relationshipStatus = FriendRelationshipStatus.CONFIRMED, allowAutomaticJoin = true, now = now, + relationshipId = relationshipId, ) @Synchronized @@ -195,6 +200,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), + relationshipId: UUID = UUID.randomUUID(), ): Either = storeInvitation( invitationUri = invitationUri, @@ -202,6 +208,7 @@ class FriendStore( relationshipStatus = FriendRelationshipStatus.PENDING_OUTGOING, now = now, + relationshipId = relationshipId, ) @Synchronized @@ -219,6 +226,7 @@ class FriendStore( relationshipStatus: FriendRelationshipStatus, allowAutomaticJoin: Boolean = false, now: Instant, + relationshipId: UUID, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) .mapLeft(FriendStoreError::InvalidInvitation) @@ -251,6 +259,7 @@ class FriendStore( publicKeyBase64 = publicKey, shareId = invite.payload.shareId, capability = invite.payload.capability, + relationshipId = existing?.relationshipId ?: relationshipId, connectAddress = invite.payload.connectAddress, internetDirectEnabled = invite.payload.internetDirectEnabled, directCandidates = invite.payload.directCandidates, @@ -372,13 +381,16 @@ class FriendStore( } @Synchronized - fun applyRemoteRemoval(peerId: String): Boolean { + fun applyRemoteRemoval( + peerId: String, + relationshipId: UUID, + ): SavedFriend? { val current = read() - if (current.none { it.peerId == peerId }) { - return false - } + val removed = current.firstOrNull { it.peerId == peerId } + ?.takeIf { it.relationshipId == relationshipId } + ?: return null write(data().copy(friends = current.filterNot { it.peerId == peerId })) - return true + return removed } @Synchronized @@ -498,6 +510,9 @@ class FriendStore( 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 connectAddress = json.optionalString("connectAddress") val internetDirectEnabled = json.optionalBoolean("internetDirectEnabled") ?: false @@ -552,6 +567,7 @@ class FriendStore( publicKeyBase64 = publicKey, shareId = shareId, capability = capability, + relationshipId = relationshipId, connectAddress = connectAddress, internetDirectEnabled = internetDirectEnabled, directCandidates = directCandidates, @@ -621,6 +637,7 @@ class FriendStore( addProperty("peerId", peerId) addProperty("publicKey", publicKeyBase64) addProperty("shareId", shareId.toString()) + addProperty("relationshipId", relationshipId.toString()) addProperty("capability", capability) connectAddress?.let { addProperty("connectAddress", it) } addProperty("internetDirectEnabled", internetDirectEnabled) @@ -701,7 +718,7 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" private const val MIN_WIRE_VERSION = 1 - private const val WIRE_VERSION = 5 + private const val WIRE_VERSION = 6 private const val MAX_FRIENDS = 256 private const val MAX_DIRECT_CANDIDATES = 4 private const val MAX_DIRECT_CANDIDATE_LENGTH = 8_192 @@ -733,6 +750,14 @@ class FriendStore( 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( 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 index cdd68dca4..f9d931afc 100644 --- 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 @@ -302,15 +302,36 @@ class FriendStoreTest { 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 - assertTrue(store.applyRemoteRemoval(PEER_ID)) - assertFalse(store.applyRemoteRemoval(PEER_ID)) + 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) 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 index 8daf811e0..1e7014276 100644 --- 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 @@ -98,7 +98,7 @@ class ConnectShare1201Runtime( val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index ad23de8a3..b041982c4 100644 --- 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 @@ -873,6 +873,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft!!.user.name, invitation = senderCard, ), 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 index d24a6ae3c..d8fe5f171 100644 --- 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 @@ -98,7 +98,7 @@ class ConnectShare1211Runtime( val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index 151c75af9..6015ac10f 100644 --- 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 @@ -867,6 +867,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft!!.user.name, invitation = senderCard, ), 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 index 7476d1671..cd589871c 100644 --- 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 @@ -114,7 +114,7 @@ class ConnectShare12111Client : ClientModInitializer { val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index 60fd509ae..d14b016cc 100644 --- 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 @@ -870,6 +870,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft.user.name, invitation = senderCard, ), 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 index 43492fc96..78dccc222 100644 --- 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 @@ -114,7 +114,7 @@ class ConnectShare262Client : ClientModInitializer { val remotePresence = FriendPresenceMonitor( store = friendStore, directProbe = { friend -> - browserReference.get()?.probeLan( + browserReference.get()?.probeDirect( friend = friend, authMode = DirectP2pAuthMode.OFFLINE, probe = statusProbe, 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 index 78bb37090..bed2082c0 100644 --- 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 @@ -870,6 +870,19 @@ class ShareJoinScreen( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId + ?: run { + target.close() + requestFailed( + peerId, + Component.translatable( + "connect_share.friends.request_failed", + ).string, + ) + return@launch + }, displayName = minecraft.user.name, invitation = senderCard, ), 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 index 2e625f0f5..e62035cc5 100644 --- 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 @@ -83,8 +83,11 @@ private class CoreFabricDirectPeerNode( override fun sign(payload: ByteArray): ByteArray = node.sign(payload) - override fun publish(invitation: String) { - node.publish(invitation) + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) } override fun openProxy( 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 index c4c79e9ca..245245034 100644 --- 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 @@ -103,7 +103,7 @@ class FabricDirectShareIngress private constructor( } else { emptyList() } - val invitation = invitation( + val invitation = createInvitation( node = node, host = host, shareId = id, @@ -112,13 +112,23 @@ class FabricDirectShareIngress private constructor( options = options, ) val currentInvitation = AtomicReference(invitation) - node.publish(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 = invitation( + val renewed = createInvitation( node = node, host = host, shareId = id, @@ -126,7 +136,19 @@ class FabricDirectShareIngress private constructor( connectAddress = connectAddress, options = options, ) - node.publish(renewed) + 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 @@ -165,7 +187,7 @@ class FabricDirectShareIngress private constructor( } } - private fun invitation( + private fun createInvitation( node: FabricDirectNode, host: DirectP2pHostInfo, shareId: UUID, @@ -278,7 +300,10 @@ internal interface FabricDirectNode : AutoCloseable { fun sign(payload: ByteArray): ByteArray - fun publish(invitation: String) + fun publish( + invitation: String, + discoveryInvitation: String, + ) } private class CoreFabricDirectNode( @@ -291,8 +316,11 @@ private class CoreFabricDirectNode( override fun sign(payload: ByteArray): ByteArray = node.sign(payload) - override fun publish(invitation: String) { - node.publish(invitation) + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) } override fun close() { 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 index 3e819f039..66758fcac 100644 --- 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 @@ -216,7 +216,7 @@ object FabricShareBootstrap { options = ShareOptions( gameMode = ShareGameMode.SURVIVAL, allowCheats = false, - allowInternetDirect = true, + allowInternetDirect = false, ), target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, @@ -281,11 +281,13 @@ object FabricShareBootstrap { ) }, ifRight = { target -> - friendRequestClient.remove( - target, - com.minekube.connect.share.friend - .FriendRemovalRequest(removal.operationId), - ) + friendRequestClient.remove( + target, + com.minekube.connect.share.friend.FriendRemovalRequest( + operationId = removal.operationId, + relationshipId = removal.friend.relationshipId, + ), + ) }, ) } 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 index 9be929bea..c80103144 100644 --- 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 @@ -248,6 +248,27 @@ class FabricShareBrowser private constructor( } else { reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) } + if (friend.internetDirectEnabled) { + 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, @@ -314,6 +335,52 @@ class FabricShareBrowser private constructor( } } + 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) { + 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() 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 index bc714e38c..337e371de 100644 --- 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 @@ -36,12 +36,23 @@ class FriendCardReceiver( displayName: String, authenticatedMinecraftUuid: UUID?, allowAutomaticJoin: Boolean = false, + relationshipId: UUID? = null, now: Instant = Instant.now(), ): Either = (if (allowAutomaticJoin) { - store.acceptAndAllowJoin(invitation, displayName, now) + store.acceptAndAllowJoin( + invitation, + displayName, + now, + relationshipId ?: UUID.randomUUID(), + ) } else { - store.accept(invitation, displayName, now) + store.accept( + invitation, + displayName, + now, + relationshipId ?: UUID.randomUUID(), + ) }).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> store.linkMinecraftIdentity( 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 index 9a54ef96e..3212ddc10 100644 --- 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 @@ -77,6 +77,7 @@ class FriendPairingClient( target = target, request = FriendControlRequest( requestId = UUID.randomUUID(), + relationshipId = pending.relationshipId, displayName = senderDisplayName, invitation = senderCard, ), 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 index ccc3ee8f3..a67841339 100644 --- 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 @@ -82,7 +82,7 @@ class FriendPresenceMonitor private constructor( description = directPresence?.description, notifyWhenOnline = friend.permissions.notifyWhenOnline, - route = directPresence?.let { ShareRoute.DIRECT_LAN }, + route = directPresence?.route ?: ShareRoute.DIRECT_LAN, ) } mutableState.value = results.toMap() 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 index 83714465f..dc27a2d6d 100644 --- 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 @@ -80,12 +80,19 @@ class FriendRequestServer( ) { FriendControlResponse.Invalid } else { - val minecraftUuid = friendStore.relationship(peerId) - .getOrNull() - ?.minecraftUuid - admission.revokeDirectPeer(peerId, minecraftUuid) - approvedJoins?.revokeDirectPeer(peerId, minecraftUuid) - if (friendStore.applyRemoteRemoval(peerId)) { + val removed = friendStore.applyRemoteRemoval( + peerId, + request.relationshipId, + ) + if (removed != null) { + admission.revokeDirectPeer( + peerId, + removed.minecraftUuid, + ) + approvedJoins?.revokeDirectPeer( + peerId, + removed.minecraftUuid, + ) notifyRelationshipChanged() } FriendControlResponse.Removed @@ -238,6 +245,7 @@ class FriendRequestServer( invitation = request.invitation, displayName = request.displayName, authenticatedMinecraftUuid = null, + relationshipId = request.relationshipId, now = instant, ) if (accepted.isLeft()) { @@ -266,6 +274,7 @@ class FriendRequestServer( invitation = request.invitation, displayName = request.displayName, authenticatedMinecraftUuid = null, + relationshipId = request.relationshipId, now = instant, ) if (received.isLeft()) { 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 index 522b18fba..30457c195 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -17,6 +18,7 @@ import kotlinx.coroutines.withContext data class ServerPresence( val description: String, + val route: ShareRoute? = null, ) sealed interface StatusProbeError { 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 index 10b20d92d..35c8565d2 100644 --- 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 @@ -48,6 +48,7 @@ data class FriendSummary( data class OutgoingFriendRequestSummary( val peerId: String, val displayName: String, + val relationshipId: UUID = UUID.randomUUID(), ) data class IncomingFriendRequestSummary( @@ -343,6 +344,7 @@ class FriendsViewModel( OutgoingFriendRequestSummary( peerId = it.peerId, displayName = it.displayName, + relationshipId = it.relationshipId, ) }, incomingRequests = incomingRequests, 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 index 9db41151c..c12461304 100644 --- 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 @@ -132,7 +132,10 @@ class FabricDirectPeerRuntimeTest { sign() } - override fun publish(invitation: String) { + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { publishes++ } 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 index c35608aca..3c0472abd 100644 --- 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 @@ -67,6 +67,14 @@ class FabricDirectShareIngressTest { 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() @@ -219,6 +227,7 @@ class FabricDirectShareIngressTest { ), ) var published: String? = null + var publishedDiscoveryInvitation: String? = null val publishedInvitations = mutableListOf() var closed = false @@ -234,11 +243,15 @@ class FabricDirectShareIngressTest { sign() } - override fun publish(invitation: String) { + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { if (failPublish) { error("publish failed") } published = invitation + publishedDiscoveryInvitation = discoveryInvitation publishedInvitations += invitation } 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 index caaad8476..eecf846ed 100644 --- 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 @@ -183,22 +183,24 @@ class FabricShareBrowserTest { authMode = DirectP2pAuthMode.OFFLINE, ) - assertIs>(result) - assertTrue(node.openedAddresses.isEmpty()) + 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: using Connect fallback", + "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() + val node = FakeGuestNode(failDirect = true) val browser = browser(node) val friend = savedFriend(invitation()) @@ -212,7 +214,33 @@ class FabricShareBrowserTest { GuestJoinFailure.EndpointConflict, result.leftOrNull(), ) - assertTrue(node.openedAddresses.isEmpty()) + 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() } @@ -249,6 +277,31 @@ class FabricShareBrowserTest { 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() 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 index e5c6bb5fd..0634266a4 100644 --- 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 @@ -280,7 +280,10 @@ class FriendPairingDirectE2ETest { assertTrue( requestClient.remove( removalTarget, - FriendRemovalRequest(removal.operationId), + FriendRemovalRequest( + operationId = removal.operationId, + relationshipId = removal.friend.relationshipId, + ), ).isRight(), ) senderStore.acknowledgeRemoval(removal.operationId) @@ -312,8 +315,11 @@ class FriendPairingDirectE2ETest { override fun sign(payload: ByteArray): ByteArray = node.sign(payload) - override fun publish(invitation: String) { - node.publish(invitation) + override fun publish( + invitation: String, + discoveryInvitation: String, + ) { + node.publish(invitation, discoveryInvitation) } override fun close() { 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 index b89caca2c..933f65017 100644 --- 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 @@ -236,7 +236,11 @@ class FriendRequestServerTest { ingress = Ingress.DIRECT_LAN, directPeerId = senderPeerId, ) - val removal = FriendRemovalRequest(UUID.randomUUID()) + val removal = FriendRemovalRequest( + operationId = UUID.randomUUID(), + relationshipId = hostStore.relationship(senderPeerId) + .getOrNull()!!.relationshipId, + ) assertEquals( FriendControlResponse.Removed, From 2e11798c6afda6bd45f617e8f1d6524058900fef Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 00:47:22 +0200 Subject: [PATCH 146/188] no-mistakes(document): Updated Share docs and cleared lint --- README.md | 6 +++--- .../specs/2026-07-30-connect-share-mod-design.md | 5 +++++ .../2026-07-30-connect-share-pasted-lan-invite-design.md | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 596552073..20599b04e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Please refer to https://connect.minekube.com for more documentation. ## Connect Share mod -Connect Share is an in-development client-side Fabric, Forge, and NeoForge mod. +Connect Share is a client-side Fabric, Forge, and NeoForge mod. It supports Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and NeoForge 1.21.1. It shares a singleplayer world through Minekube Connect or directly between two modded clients without exposing Minecraft's listener to @@ -33,8 +33,8 @@ The current implementation provides: - a stable `*.play.minekube.net` address for unmodified Java clients; - signed friend links and temporary world invitations for modded clients; - automatic same-LAN discovery and direct libp2p transport; -- direct libp2p friend delivery across LANs from explicitly shared friend links, - plus opt-in internet-direct gameplay attempts; +- direct libp2p friend delivery from explicitly shared friend links when a + direct route exists, plus opt-in internet-direct gameplay attempts; - exactly-once fallback to Connect, which is the only relay; - host approval before each new guest reaches the world; - explicit support for authenticated and unverified offline-mode guests; and 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 index 2c0491152..fa40efc16 100644 --- a/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md +++ b/docs/superpowers/specs/2026-07-30-connect-share-mod-design.md @@ -4,6 +4,11 @@ **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 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 index 8a393008a..3b4542f8a 100644 --- 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 @@ -4,6 +4,10 @@ **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 From d26548c7a388f229449beae1d45e4cbee6b1e7a2 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 01:34:19 +0200 Subject: [PATCH 147/188] no-mistakes(review): Fix Share consent, convergence, and wire compatibility --- .../connect/share/friend/FriendControlWire.kt | 55 +++++++++++++- .../connect/share/friend/FriendStore.kt | 65 ++++++++++++---- .../share/friend/FriendControlWireTest.kt | 68 +++++++++++++++++ .../connect/share/friend/FriendStoreTest.kt | 75 +++++++++++++++++++ .../fabric/v1_20_1/FriendCardNetworking.kt | 8 +- .../share/fabric/v1_20_1/FriendCardPayload.kt | 8 +- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 23 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v1_20_1/FriendCardPayloadTest.kt | 11 +-- .../fabric/v1_21_1/FriendCardNetworking.kt | 6 +- .../share/fabric/v1_21_1/FriendCardPayload.kt | 6 +- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 21 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v1_21_1/FriendCardPayloadTest.kt | 11 +-- .../fabric/v1_21_11/FriendCardNetworking.kt | 6 +- .../fabric/v1_21_11/FriendCardPayload.kt | 6 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 21 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v1_21_11/FriendCardPayloadTest.kt | 11 +-- .../fabric/v26_2/FriendCardNetworking.kt | 6 +- .../share/fabric/v26_2/FriendCardPayload.kt | 6 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 21 +++++- .../assets/connect-share/lang/de_de.json | 3 +- .../assets/connect-share/lang/en_us.json | 3 +- .../fabric/v26_2/FriendCardPayloadTest.kt | 11 +-- .../share/fabric/ConnectShareClient.kt | 6 +- .../share/fabric/FabricDirectShareIngress.kt | 3 +- .../share/fabric/FabricShareBootstrap.kt | 30 ++++++-- .../share/fabric/FabricShareBrowser.kt | 6 +- .../share/fabric/FriendCardExchangeConsent.kt | 7 +- .../connect/share/fabric/FriendCardIssuer.kt | 8 +- .../share/fabric/FriendPairingClient.kt | 1 + .../share/fabric/FriendRequestServer.kt | 25 +++---- .../share/fabric/ui/FriendsViewModel.kt | 17 +++++ .../share/fabric/FabricShareBootstrapTest.kt | 8 ++ .../share/fabric/FabricShareBrowserTest.kt | 19 +++++ .../fabric/FriendCardExchangeConsentTest.kt | 9 +++ .../v1_20_1/ForgeFriendCardNetworking.kt | 22 +++++- .../v1_21_1/NeoForgeFriendCardNetworking.kt | 16 +++- 42 files changed, 547 insertions(+), 99 deletions(-) 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 index c3e049c65..4ed1992e3 100644 --- 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 @@ -142,7 +142,7 @@ object FriendControlWire { if (bytes.size > MAX_REQUEST_BYTES) { return FriendControlDecode.Invalid } - return decode(bytes) { + val current = decode(bytes) { val control = readPacket() ensure(control.readVarInt() == CONTROL_REQUEST_PACKET_ID) val requestId = UUID( @@ -167,6 +167,37 @@ object FriendControlWire { 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 { @@ -187,7 +218,7 @@ object FriendControlWire { if (bytes.size > MAX_REQUEST_BYTES) { return FriendControlDecode.Invalid } - return decode(bytes) { + val current = decode(bytes) { val control = readPacket() ensure(control.readVarInt() == CONTROL_REMOVAL_PACKET_ID) val request = FriendRemovalRequest( @@ -197,6 +228,26 @@ object FriendControlWire { 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 = 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 index 8c8ffd336..04c41ab81 100644 --- 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 @@ -68,9 +68,11 @@ data class SavedFriend( 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(), @@ -169,7 +171,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), - relationshipId: UUID = UUID.randomUUID(), + relationshipId: UUID? = null, ): Either = storeInvitation( invitationUri = invitationUri, @@ -184,7 +186,7 @@ class FriendStore( invitationUri: String, displayName: String, now: Instant = Instant.now(), - relationshipId: UUID = UUID.randomUUID(), + relationshipId: UUID? = null, ): Either = storeInvitation( invitationUri = invitationUri, @@ -226,7 +228,7 @@ class FriendStore( relationshipStatus: FriendRelationshipStatus, allowAutomaticJoin: Boolean = false, now: Instant, - relationshipId: UUID, + relationshipId: UUID?, ): Either = either { val invite = ShareInviteCodec.decode(invitationUri.trim(), now) .mapLeft(FriendStoreError::InvalidInvitation) @@ -259,10 +261,22 @@ class FriendStore( publicKeyBase64 = publicKey, shareId = invite.payload.shareId, capability = invite.payload.capability, - relationshipId = existing?.relationshipId ?: relationshipId, + 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 = existing?.internetDirectGuestOptIn == true, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = (existing?.permissions ?: FriendPermissions()) @@ -310,6 +324,14 @@ class FriendStore( friend.copy(permissions = permissions) } + @Synchronized + fun setInternetDirectGuestOptIn( + peerId: String, + enabled: Boolean, + ): Either = update(peerId) { friend -> + friend.copy(internetDirectGuestOptIn = enabled) + } + @Synchronized fun linkMinecraftIdentity( peerId: String, @@ -387,7 +409,9 @@ class FriendStore( ): SavedFriend? { val current = read() val removed = current.firstOrNull { it.peerId == peerId } - ?.takeIf { it.relationshipId == relationshipId } + ?.takeIf { + it.relationshipIdKnown && it.relationshipId == relationshipId + } ?: return null write(data().copy(friends = current.filterNot { it.peerId == peerId })) return removed @@ -441,7 +465,7 @@ class FriendStore( val entries = root.getAsJsonArray("friends") ?: throw IOException("Friends file is missing friends") val friends = entries.map { element -> - parseFriend(element.asJsonObject) + parseFriend(element.asJsonObject, version) } if (friends.size > MAX_FRIENDS) { throw IOException("Friends file contains too many entries") @@ -451,7 +475,7 @@ class FriendStore( } val removals = if (version >= 2) { root.getAsJsonArray("pendingRemovals") - ?.map { element -> parseRemoval(element.asJsonObject) } + ?.map { element -> parseRemoval(element.asJsonObject, version) } ?: emptyList() } else { emptyList() @@ -479,13 +503,15 @@ class FriendStore( } } - private fun parseRemoval(json: JsonObject): PendingFriendRemoval = + private fun parseRemoval( + json: JsonObject, + version: Int, + ): PendingFriendRemoval = PendingFriendRemoval( operationId = UUID.fromString(json.requiredString("operationId")), - friend = parseFriend( - json.getAsJsonObject("friend") - ?: throw IOException("Removal is missing friend"), - ), + 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"), @@ -505,7 +531,10 @@ class FriendStore( ), ) - private fun parseFriend(json: JsonObject): SavedFriend { + 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")) @@ -513,12 +542,16 @@ class FriendStore( 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) @@ -568,9 +601,11 @@ class FriendStore( shareId = shareId, capability = capability, relationshipId = relationshipId, + relationshipIdKnown = relationshipIdKnown, connectAddress = connectAddress, internetDirectEnabled = internetDirectEnabled, directCandidates = directCandidates, + internetDirectGuestOptIn = internetDirectGuestOptIn, displayName = displayName, minecraftUuid = minecraftUuid, permissions = parsedPermissions, @@ -638,12 +673,14 @@ class FriendStore( 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) @@ -718,7 +755,7 @@ class FriendStore( companion object { const val FILE_NAME = "friends.json" private const val MIN_WIRE_VERSION = 1 - private const val WIRE_VERSION = 6 + 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 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 index ea1190c1f..22846f7d1 100644 --- 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 @@ -1,5 +1,6 @@ package com.minekube.connect.share.friend +import java.io.ByteArrayOutputStream import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -27,6 +28,22 @@ class FriendControlWireTest { 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( @@ -117,6 +134,16 @@ class FriendControlWireTest { ) } + @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( @@ -142,5 +169,46 @@ class FriendControlWireTest { 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 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 index f9d931afc..180b839aa 100644 --- 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 @@ -76,6 +76,21 @@ class FriendStoreTest { 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 `confirming an outgoing request promotes it across restarts`() { val store = FriendStore(tempDir) @@ -113,6 +128,66 @@ class FriendStoreTest { 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) 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 index 3f2aea4ff..46ac46f6f 100644 --- 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 @@ -22,8 +22,8 @@ object FriendCardNetworking { ServerPlayNetworking.registerGlobalReceiver( FriendCardChannels.CARD, ) { server, player, _, buffer, _ -> - val invitation = runCatching { - buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS) + val payload = runCatching { + FriendCardCodec.decode(buffer) }.getOrNull() ?: return@registerGlobalReceiver server.execute { val proof = approvedJoins.consume( @@ -31,10 +31,11 @@ object FriendCardNetworking { player.uuid, ) ?: return@execute receiver.receive( - invitation = invitation, + invitation = payload.invitation, displayName = player.gameProfile.name, authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -71,6 +72,7 @@ object FriendCardNetworking { 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 index 5824e04dc..84f384b08 100644 --- 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 @@ -2,9 +2,11 @@ package com.minekube.connect.share.fabric.v1_20_1 import net.minecraft.resources.ResourceLocation import net.minecraft.network.FriendlyByteBuf +import java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) { companion object { val CODEC = FriendCardCodec @@ -18,10 +20,14 @@ data object FriendCardRequestPayload { 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(buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS)) + FriendCardPayload( + invitation = buffer.readUtf(FriendCardChannels.MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), + ) } object FriendCardRequestCodec { 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 index b041982c4..efd89e3da 100644 --- 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 @@ -576,7 +576,7 @@ class ShareJoinScreen( .withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -592,10 +592,22 @@ class ShareJoinScreen( friend.permissions.canSeeMyWorlds, ), ) + val guestInternetDirect = addRenderableWidget( + ObservableCheckbox( + width / 2 - 155, + 126, + 310, + 20, + Component.translatable( + "connect_share.friends.internet_direct", + ), + friend.internetDirectGuestOptIn, + ), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154), + centered(Component.literal(message), 176), ) } } @@ -609,6 +621,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -913,6 +929,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 7e8e41b0d..d88fb1ba5 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index 3e71098d5..2387e836e 100644 --- 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 @@ -42,6 +42,7 @@ object FriendCardNetworking { authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -82,7 +83,10 @@ object FriendCardNetworking { ) ) { ClientPlayNetworking.send( - FriendCardPayload(invitation), + 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 index 19d094f6e..deccf15b4 100644 --- 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 @@ -4,9 +4,11 @@ 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 java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -28,10 +30,12 @@ data class FriendCardPayload( payload.invitation, MAX_CARD_CHARS, ) + buffer.writeUUID(payload.relationshipId) }, { buffer -> FriendCardPayload( - buffer.readUtf(MAX_CARD_CHARS), + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), ) }, ) 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 index 6015ac10f..060a8c690 100644 --- 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 @@ -572,7 +572,7 @@ class ShareJoinScreen( .withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -586,10 +586,20 @@ class ShareJoinScreen( .selected(friend.permissions.canSeeMyWorlds) .build(), ) + val guestInternetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable( + "connect_share.friends.internet_direct", + ), + font, + ).pos(width / 2 - 155, 126) + .selected(friend.internetDirectGuestOptIn) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154), + centered(Component.literal(message), 176), ) } } @@ -603,6 +613,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -907,6 +921,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 7fdf17982..ac78d8857 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index 294da7970..ddc97a07d 100644 --- 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 @@ -42,6 +42,7 @@ object FriendCardNetworking { authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -82,7 +83,10 @@ object FriendCardNetworking { ) ) { ClientPlayNetworking.send( - FriendCardPayload(invitation), + 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 index a2a74ea40..f0b67c81d 100644 --- 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 @@ -4,9 +4,11 @@ import net.minecraft.network.FriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload import net.minecraft.resources.Identifier +import java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -28,10 +30,12 @@ data class FriendCardPayload( payload.invitation, MAX_CARD_CHARS, ) + buffer.writeUUID(payload.relationshipId) }, { buffer -> FriendCardPayload( - buffer.readUtf(MAX_CARD_CHARS), + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), ) }, ) 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 index d14b016cc..ae8d1b7ea 100644 --- 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 @@ -574,7 +574,7 @@ class ShareJoinScreen( ).withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -588,10 +588,20 @@ class ShareJoinScreen( .selected(friend.permissions.canSeeMyWorlds) .build(), ) + val guestInternetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable( + "connect_share.friends.internet_direct", + ), + font, + ).pos(width / 2 - 155, 126) + .selected(friend.internetDirectGuestOptIn) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154) + centered(Component.literal(message), 176) .setMaxWidth(CONTENT_WIDTH), ) } @@ -606,6 +616,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -910,6 +924,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 79161088e..629b6fd94 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index ad5464aa4..db7a3de3c 100644 --- 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 @@ -42,6 +42,7 @@ object FriendCardNetworking { authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -82,7 +83,10 @@ object FriendCardNetworking { ) ) { ClientPlayNetworking.send( - FriendCardPayload(invitation), + 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 index 54f6f1350..99fd5056b 100644 --- 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 @@ -4,9 +4,11 @@ import net.minecraft.network.FriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload import net.minecraft.resources.Identifier +import java.util.UUID data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -28,10 +30,12 @@ data class FriendCardPayload( payload.invitation, MAX_CARD_CHARS, ) + buffer.writeUUID(payload.relationshipId) }, { buffer -> FriendCardPayload( - buffer.readUtf(MAX_CARD_CHARS), + invitation = buffer.readUtf(MAX_CARD_CHARS), + relationshipId = buffer.readUUID(), ) }, ) 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 index bed2082c0..bb7a34451 100644 --- 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 @@ -574,7 +574,7 @@ class ShareJoinScreen( ).withValues(FriendAccessPolicy.entries) .create( width / 2 - 155, - 126, + 148, 310, 20, Component.translatable("connect_share.friends.access"), @@ -588,10 +588,20 @@ class ShareJoinScreen( .selected(friend.permissions.canSeeMyWorlds) .build(), ) + val guestInternetDirect = addRenderableWidget( + Checkbox.builder( + Component.translatable( + "connect_share.friends.internet_direct", + ), + font, + ).pos(width / 2 - 155, 126) + .selected(friend.internetDirectGuestOptIn) + .build(), + ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 154) + centered(Component.literal(message), 176) .setMaxWidth(CONTENT_WIDTH), ) } @@ -606,6 +616,10 @@ class ShareJoinScreen( activeScope.launch { withContext(Dispatchers.IO) { friends.rename(friend.peerId, nameValue) + friends.updateInternetDirectGuestOptIn( + friend.peerId, + guestInternetDirect.selected(), + ) friends.updatePermissions( friend.peerId, FriendPermissions( @@ -910,6 +924,9 @@ class ShareJoinScreen( invitation = hostCard, displayName = displayName, authenticatedMinecraftUuid = null, + relationshipId = friends.state.value.outgoingRequests + .firstOrNull { it.peerId == peerId } + ?.relationshipId, ) } if (accepted.isLeft()) { 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 index cf0166279..6aded9c15 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnose kopiert", + "connect_share.friends.internet_direct": "Direkte Internetverbindungen für diesen Freund erlauben" } 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 index ece7e5c99..ab376b8a5 100644 --- 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 @@ -145,5 +145,6 @@ "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.diagnostics.copied": "Diagnostics copied", + "connect_share.friends.internet_direct": "Allow direct internet routes for this friend" } 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 index 90b026376..71dc515a2 100644 --- 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 @@ -5,22 +5,23 @@ 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), + FriendCardPayload(invitation, relationshipId), ) - assertEquals( - invitation, - FriendCardPayload.CODEC.decode(buffer).invitation, - ) + val decoded = FriendCardPayload.CODEC.decode(buffer) + assertEquals(invitation, decoded.invitation) + assertEquals(relationshipId, decoded.relationshipId) } @Test 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 index 7c8e7e5eb..2db152629 100644 --- 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 @@ -132,7 +132,11 @@ object ConnectShareClient { @JvmStatic fun armFriendCardExchange(peerId: String) { - friendCardConsent.arm(peerId) + installation?.friendsViewModel + ?.relationshipId(peerId) + ?.let { relationshipId -> + friendCardConsent.arm(peerId, relationshipId) + } } @JvmStatic 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 index 245245034..7dcf9058a 100644 --- 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 @@ -47,10 +47,11 @@ class FabricDirectShareIngress private constructor( accessIdentityStore: ShareAccessIdentityStore = ShareAccessIdentityStore(dataDirectory), displayName: () -> String, + identityFile: Path = dataDirectory.resolve(IDENTITY_FILE_NAME), ) : this( nodeFactory = { CoreFabricDirectNode( - DirectP2pNode(dataDirectory.resolve(IDENTITY_FILE_NAME)), + DirectP2pNode(identityFile), ) }, now = Instant::now, 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 index 66758fcac..fb90a2c12 100644 --- 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 @@ -122,11 +122,15 @@ object FabricShareBootstrap { ) 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() @@ -192,7 +196,15 @@ object FabricShareBootstrap { val directIngress = PersistentDirectIngress( directPeer.ingress, ) - directIngressReference.set(directIngress) + val socialIngress = PersistentDirectIngress( + FabricDirectShareIngress( + dataDirectory = dataDirectory, + accessIdentityStore = accessIdentityStore, + displayName = worldDisplayName, + identityFile = socialIdentityFile, + ), + ) + directIngressReference.set(socialIngress) val coordinator = ShareCoordinator( bridge = bridge, ingress = ingress, @@ -212,12 +224,8 @@ object FabricShareBootstrap { startedControlPlane.start() val startedDirectControlPlane = DirectControlPlane( scope = scope, - ingress = directIngress, - options = ShareOptions( - gameMode = ShareGameMode.SURVIVAL, - allowCheats = false, - allowInternetDirect = false, - ), + ingress = socialIngress, + options = socialControlOptions(), target = gateway.directAddress, connectAddress = { ownConnectAddress.get() }, failureReporter = logger::warn, @@ -400,6 +408,12 @@ object FabricShareBootstrap { ).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)}" @@ -418,6 +432,8 @@ object FabricShareBootstrap { 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( 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 index c80103144..70f77ea59 100644 --- 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 @@ -248,7 +248,7 @@ class FabricShareBrowser private constructor( } else { reportRoute(ROUTE_DIRECT_LAN_UNAVAILABLE) } - if (friend.internetDirectEnabled) { + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { var attempted = false for (address in friend.directCandidates) { attempted = true @@ -300,7 +300,7 @@ class FabricShareBrowser private constructor( timeout = LAN_TIMEOUT, )?.let { return@withContext it.right() } } - if (friend.internetDirectEnabled) { + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { for (address in friend.directCandidates) { openDirect( route = ShareRoute.DIRECT_INTERNET, @@ -358,7 +358,7 @@ class FabricShareBrowser private constructor( } } } - if (friend.internetDirectEnabled) { + if (friend.internetDirectEnabled && friend.internetDirectGuestOptIn) { for (address in friend.directCandidates) { val direct = openDirect( route = ShareRoute.DIRECT_INTERNET, 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 index 8e9eb9da3..26224c86b 100644 --- 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 @@ -1,7 +1,10 @@ package com.minekube.connect.share.fabric +import java.util.UUID + data class FriendCardExchangeProof( val peerId: String, + val relationshipId: UUID, ) class FriendCardExchangeConsent( @@ -10,10 +13,10 @@ class FriendCardExchangeConsent( private var armed: TimedExchange? = null @Synchronized - fun arm(peerId: String) { + fun arm(peerId: String, relationshipId: UUID = UUID.randomUUID()) { require(peerId.isNotBlank()) armed = TimedExchange( - proof = FriendCardExchangeProof(peerId), + proof = FriendCardExchangeProof(peerId, relationshipId), armedAtMillis = nowMillis(), ) } 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 index 337e371de..5fd6eb965 100644 --- 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 @@ -44,14 +44,14 @@ class FriendCardReceiver( invitation, displayName, now, - relationshipId ?: UUID.randomUUID(), + relationshipId, ) } else { store.accept( invitation, displayName, now, - relationshipId ?: UUID.randomUUID(), + relationshipId, ) }).flatMap { friend -> authenticatedMinecraftUuid?.let { minecraftUuid -> @@ -69,6 +69,8 @@ class FriendCardIssuer( 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( @@ -84,7 +86,7 @@ class FriendCardIssuer( Either.catch { val access = accessIdentityStore.currentOrCreate() DirectP2pNode( - dataDirectory.resolve(IDENTITY_FILE_NAME), + identityFile, ).use { node -> val route = directRoute() val payload = ShareInvitePayload( 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 index 3212ddc10..44179d314 100644 --- 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 @@ -87,6 +87,7 @@ class FriendPairingClient( 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/FriendRequestServer.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FriendRequestServer.kt index dc27a2d6d..f7096b43a 100644 --- 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 @@ -237,22 +237,17 @@ class FriendRequestServer( if (existing.publicKeyBase64 != senderKey) { return FriendControlResponse.Invalid } - if ( - existing.relationshipStatus == - FriendRelationshipStatus.PENDING_OUTGOING - ) { - val accepted = receiver.receive( - invitation = request.invitation, - displayName = request.displayName, - authenticatedMinecraftUuid = null, - relationshipId = request.relationshipId, - now = instant, - ) - if (accepted.isLeft()) { - return FriendControlResponse.Invalid - } - notifyRelationshipChanged() + 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) } 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 index 35c8565d2..3782d334c 100644 --- 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 @@ -35,6 +35,7 @@ data class FriendSummary( 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, @@ -136,6 +137,21 @@ class FriendsViewModel( ) } + fun updateInternetDirectGuestOptIn( + peerId: String, + enabled: Boolean, + ) { + store.setInternetDirectGuestOptIn(peerId, enabled).fold( + ifLeft = { failure -> + update { copy(safeMessage = failure.safeMessage) } + }, + ifRight = { refresh() }, + ) + } + + internal fun relationshipId(peerId: String): UUID? = + store.relationship(peerId).getOrNull()?.relationshipId + fun remove(peerId: String): Boolean = Either.catch { store.remove(peerId) @@ -376,6 +392,7 @@ class FriendsViewModel( displayName = displayName, connectAvailable = connectAddress != null, permissions = permissions, + internetDirectGuestOptIn = internetDirectGuestOptIn, onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, onlineViaConnect = remote?.route == ShareRoute.CONNECT, worldName = remote?.description, 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 index 04761ce0e..f65afe4c2 100644 --- 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 @@ -2,6 +2,7 @@ package com.minekube.connect.share.fabric import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class FabricShareBootstrapTest { @Test @@ -17,4 +18,11 @@ class FabricShareBootstrapTest { ).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 index eecf846ed..4bc271b9d 100644 --- 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 @@ -162,6 +162,24 @@ class FabricShareBrowserTest { 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() @@ -458,6 +476,7 @@ class FabricShareBrowserTest { connectAddress = invitation.payload.connectAddress, internetDirectEnabled = invitation.payload.internetDirectEnabled, directCandidates = invitation.payload.directCandidates, + internetDirectGuestOptIn = true, displayName = "Robin", ) } 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 index d177e8b90..f9b60ab67 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -34,6 +35,14 @@ class FriendCardExchangeConsentTest { 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( 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 index 2048c9670..c8575f282 100644 --- 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 @@ -18,6 +18,7 @@ import net.minecraftforge.network.NetworkDirection import net.minecraftforge.network.NetworkRegistry import net.minecraftforge.network.PacketDistributor import net.minecraftforge.network.simple.SimpleChannel +import java.util.UUID object ForgeFriendCardNetworking { private const val PROTOCOL = "1" @@ -49,8 +50,16 @@ object ForgeFriendCardNetworking { 0, NetworkDirection.PLAY_TO_SERVER, ) - .encoder { message, buffer -> buffer.writeUtf(message.invitation, MAX_CARD_CHARS) } - .decoder { buffer -> FriendCardMessage(buffer.readUtf(MAX_CARD_CHARS)) } + .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 @@ -64,6 +73,7 @@ object ForgeFriendCardNetworking { displayName = player.gameProfile.name, authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = message.relationshipId, ) } } @@ -84,7 +94,12 @@ object ForgeFriendCardNetworking { handlers.issuer.issue().getOrNull()?.let { invitation -> Minecraft.getInstance().execute { if (Minecraft.getInstance().connection != null) { - channel.sendToServer(FriendCardMessage(invitation)) + channel.sendToServer( + FriendCardMessage( + invitation, + exchange.relationshipId, + ), + ) handlers.scope.launch(Dispatchers.IO) { handlers.receiver.confirmOutgoing(exchange.peerId) } @@ -115,6 +130,7 @@ object ForgeFriendCardNetworking { private data class FriendCardMessage( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) private data object FriendCardRequestMessage 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 index 5a2de7f24..aeb13b987 100644 --- 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 @@ -16,6 +16,7 @@ import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import net.neoforged.neoforge.network.PacketDistributor import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent +import java.util.UUID object NeoForgeFriendCardNetworking { private const val PROTOCOL = "1" @@ -36,7 +37,10 @@ object NeoForgeFriendCardNetworking { Minecraft.getInstance().execute { if (Minecraft.getInstance().connection != null) { PacketDistributor.sendToServer( - FriendCardPayload(invitation), + FriendCardPayload( + invitation, + exchange.relationshipId, + ), ) handlers.scope.launch(Dispatchers.IO) { handlers.receiver.confirmOutgoing(exchange.peerId) @@ -63,6 +67,7 @@ object NeoForgeFriendCardNetworking { displayName = player.gameProfile.name, authenticatedMinecraftUuid = proof.authenticatedMinecraftUuid, allowAutomaticJoin = true, + relationshipId = payload.relationshipId, ) } } @@ -97,6 +102,7 @@ object NeoForgeFriendCardNetworking { private data class FriendCardPayload( val invitation: String, + val relationshipId: UUID = UUID.randomUUID(), ) : CustomPacketPayload { override fun type(): CustomPacketPayload.Type = TYPE @@ -113,8 +119,14 @@ private data class FriendCardPayload( CustomPacketPayload.codec( { payload, buffer -> buffer.writeUtf(payload.invitation, MAX_CARD_CHARS) + buffer.writeUUID(payload.relationshipId) + }, + { buffer -> + FriendCardPayload( + buffer.readUtf(MAX_CARD_CHARS), + buffer.readUUID(), + ) }, - { buffer -> FriendCardPayload(buffer.readUtf(MAX_CARD_CHARS)) }, ) } } From 8c924409a703e5fbe5cd0af9ee49603098ba147b Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 02:14:07 +0200 Subject: [PATCH 148/188] no-mistakes(document): Updated Share docs and normalized imports --- docs/connect-share-testing.md | 26 ++++++++++++------ docs/connect-share.md | 27 ++++++++++++------- .../share/fabric/v1_20_1/FriendCardPayload.kt | 4 +-- .../share/fabric/v1_21_1/FriendCardPayload.kt | 2 +- .../fabric/v1_21_1/FriendCardPayloadTest.kt | 2 +- .../fabric/v1_21_11/FriendCardPayload.kt | 2 +- .../fabric/v1_21_11/FriendCardPayloadTest.kt | 2 +- .../share/fabric/v26_2/FriendCardPayload.kt | 2 +- .../fabric/v26_2/FriendCardPayloadTest.kt | 2 +- .../v1_20_1/ForgeFriendCardNetworking.kt | 2 +- .../v1_21_1/NeoForgeFriendCardNetworking.kt | 2 +- 11 files changed, 45 insertions(+), 28 deletions(-) diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 2d1e4a592..1aabf2885 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -98,17 +98,27 @@ address, such as a publicly routed host or an explicitly configured network. 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. Copying a friend link is an -explicit disclosure action and may include signed direct candidates. A saved -friend tries fresh mDNS first, then those candidates; requests, presence, and -removal must never use Connect. +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 internet-direct disabled on either peer, confirm the guest does not - attempt a direct internet route and uses Connect once. -3. Enable internet-direct on both peers. Confirm both UIs disclose that the - path reveals public IP addresses before it is attempted. +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 diff --git a/docs/connect-share.md b/docs/connect-share.md index 539e5f2b4..0982d7ca4 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -19,11 +19,14 @@ IP addresses or create a new link for every world. requests and presence themselves are authenticated libp2p traffic and never use Connect as a social relay. -Friend links carry signed direct candidates when the local libp2p 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. A reciprocal card exchange refreshes saved candidates when -friends reconnect from a new network. No circuit relay is accepted. +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, @@ -53,11 +56,15 @@ or blocking cannot be bypassed with an old attempt. - 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. 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. +- 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. 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 index 84f384b08..151a977c3 100644 --- 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 @@ -1,8 +1,8 @@ package com.minekube.connect.share.fabric.v1_20_1 -import net.minecraft.resources.ResourceLocation -import net.minecraft.network.FriendlyByteBuf import java.util.UUID +import net.minecraft.network.FriendlyByteBuf +import net.minecraft.resources.ResourceLocation data class FriendCardPayload( val invitation: String, 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 index deccf15b4..517b518d7 100644 --- 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 @@ -1,10 +1,10 @@ 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 -import java.util.UUID data class FriendCardPayload( val invitation: String, 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 index ac78d8857..e5a1163d6 100644 --- 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 @@ -1,11 +1,11 @@ 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 -import java.util.UUID class FriendCardPayloadTest { @Test 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 index f0b67c81d..e4a7bed57 100644 --- 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 @@ -1,10 +1,10 @@ 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 -import java.util.UUID data class FriendCardPayload( val invitation: String, 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 index 629b6fd94..746bd4980 100644 --- 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 @@ -1,11 +1,11 @@ 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 -import java.util.UUID class FriendCardPayloadTest { @Test 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 index 99fd5056b..dc048c503 100644 --- 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 @@ -1,10 +1,10 @@ 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 -import java.util.UUID data class FriendCardPayload( val invitation: String, 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 index 71dc515a2..12e48bee9 100644 --- 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 @@ -1,11 +1,11 @@ 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 -import java.util.UUID class FriendCardPayloadTest { @Test 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 index c8575f282..40c78ded2 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -18,7 +19,6 @@ import net.minecraftforge.network.NetworkDirection import net.minecraftforge.network.NetworkRegistry import net.minecraftforge.network.PacketDistributor import net.minecraftforge.network.simple.SimpleChannel -import java.util.UUID object ForgeFriendCardNetworking { private const val PROTOCOL = "1" 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 index aeb13b987..0ede63d1e 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -16,7 +17,6 @@ import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import net.neoforged.neoforge.network.PacketDistributor import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent -import java.util.UUID object NeoForgeFriendCardNetworking { private const val PROTOCOL = "1" From 6a2d5d8c3b8569c642634214789a6bd33938a45f Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 02:29:34 +0200 Subject: [PATCH 149/188] no-mistakes(review): Persisted internet consent across all Fabric friend requests --- .../connect/share/friend/FriendStore.kt | 6 +++++- .../connect/share/friend/FriendStoreTest.kt | 20 +++++++++++++++++++ .../share/fabric/v1_20_1/ShareJoinScreen.kt | 1 + .../share/fabric/v1_21_1/ShareJoinScreen.kt | 1 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 1 + .../share/fabric/v26_2/ShareJoinScreen.kt | 1 + .../share/fabric/ui/FriendsViewModel.kt | 8 +++++++- .../share/fabric/ui/FriendsViewModelTest.kt | 17 ++++++++++++++++ 8 files changed, 53 insertions(+), 2 deletions(-) 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 index 04c41ab81..9873a9ac8 100644 --- 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 @@ -203,6 +203,7 @@ class FriendStore( displayName: String, now: Instant = Instant.now(), relationshipId: UUID = UUID.randomUUID(), + internetDirectGuestOptIn: Boolean = false, ): Either = storeInvitation( invitationUri = invitationUri, @@ -211,6 +212,7 @@ class FriendStore( FriendRelationshipStatus.PENDING_OUTGOING, now = now, relationshipId = relationshipId, + internetDirectGuestOptIn = internetDirectGuestOptIn, ) @Synchronized @@ -227,6 +229,7 @@ class FriendStore( displayName: String, relationshipStatus: FriendRelationshipStatus, allowAutomaticJoin: Boolean = false, + internetDirectGuestOptIn: Boolean = false, now: Instant, relationshipId: UUID?, ): Either = either { @@ -276,7 +279,8 @@ class FriendStore( connectAddress = invite.payload.connectAddress, internetDirectEnabled = invite.payload.internetDirectEnabled, directCandidates = invite.payload.directCandidates, - internetDirectGuestOptIn = existing?.internetDirectGuestOptIn == true, + internetDirectGuestOptIn = internetDirectGuestOptIn || + existing?.internetDirectGuestOptIn == true, displayName = existing?.displayName ?: normalizedName, minecraftUuid = existing?.minecraftUuid, permissions = (existing?.permissions ?: FriendPermissions()) 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 index 180b839aa..76c3b0f1f 100644 --- 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 @@ -91,6 +91,26 @@ class FriendStoreTest { 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) 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 index efd89e3da..be7c3dc63 100644 --- 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 @@ -829,6 +829,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index 060a8c690..bbb753321 100644 --- 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 @@ -821,6 +821,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index ae8d1b7ea..c4d1f6397 100644 --- 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 @@ -824,6 +824,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index bb7a34451..89780d586 100644 --- 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 @@ -824,6 +824,7 @@ class ShareJoinScreen( friends.sendRequest( invitationValue, nameValue, + internetDirectGuestOptIn = internetSelected, ) } requestOperationInProgress = false 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 index 3782d334c..268e88714 100644 --- 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 @@ -92,8 +92,14 @@ class FriendsViewModel( invitationUri: String, displayName: String, now: Instant = Instant.now(), + internetDirectGuestOptIn: Boolean = false, ): String? = - store.sendRequest(invitationUri, displayName, now).fold( + store.sendRequest( + invitationUri = invitationUri, + displayName = displayName, + now = now, + internetDirectGuestOptIn = internetDirectGuestOptIn, + ).fold( ifLeft = { failure -> update { copy(safeMessage = failure.safeMessage) } null 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 index ae367a183..462052f60 100644 --- 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 @@ -59,6 +59,23 @@ class FriendsViewModelTest { 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)) From d98745e6b413a10a2bcff62de0ac0c43c6996f57 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 04:00:19 +0200 Subject: [PATCH 150/188] no-mistakes(document): Corrected Connect Share docs and acceptance paths --- .../skills/connect-share-prism-e2e/SKILL.md | 5 +++ README.md | 15 +++------ docs/connect-share-testing.md | 33 +++++++++---------- docs/connect-share.md | 9 ++--- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index e94796bec..dccfc1087 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -9,6 +9,11 @@ Use the repository's opt-in live harness to prove the complete friend-to-world flow. Treat discovery, activity, 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. diff --git a/README.md b/README.md index 20599b04e..c13564391 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,9 @@ Please refer to https://connect.minekube.com for more documentation. ## Connect Share mod Connect Share is a client-side Fabric, Forge, and NeoForge mod. -It supports Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2; Forge 1.20.1; and -NeoForge 1.21.1. It shares a singleplayer world through Minekube Connect or -directly between two modded clients without exposing Minecraft's listener to -the LAN or internet. +It shares a singleplayer world through Minekube Connect or directly between +two modded clients without exposing Minecraft's listener to the LAN or +internet. The current implementation provides: @@ -42,12 +41,8 @@ The current implementation provides: - follow-next-session intents that never interrupt active gameplay; and - isolated, version-and-loader-labelled artifacts for every supported target. -Fabric builds require Fabric API and Fabric Language Kotlin. Forge and NeoForge -builds require the installable Kotlin for Forge `-all.jar`. Marketplace release -metadata declares the matching dependencies so compatible launchers, including -Prism, can install them automatically. Connect Share is MIT licensed and may be -included in modpacks without asking for additional permission. See -[the player, privacy, and distribution guide](docs/connect-share.md). +See [the player, privacy, installation, and distribution guide](docs/connect-share.md) +for the supported versions, required dependencies, and release details. The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 1aabf2885..adb33b1f1 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -1,11 +1,11 @@ # Connect Share acceptance -Connect Share is built separately for Fabric 1.20.1, 1.21.1, 1.21.11, and 26.2, -Forge 1.20.1, and NeoForge 1.21.1 on their respective Java toolchains. 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. +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. @@ -25,13 +25,9 @@ From the repository root: Use the unclassified versioned JAR in each module's `build/libs` directory. Do not install `sources`, `dev`, `unshaded`, or `parent-shadow` artifacts. -Install the matching Fabric Loader, Fabric API, and Fabric Language Kotlin. -Marketplace installs must resolve the latter two automatically. - -For Forge or NeoForge, install the matching loader and Kotlin for Forge. A -manual install must use Kotlin for Forge's `-all.jar`; its plain Maven artifact -is only a compile/library artifact and is not recognized as the loader mod. -Marketplace installs must resolve Kotlin for Forge automatically. +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 @@ -94,7 +90,7 @@ route works. ## Invitation, internet-direct, and fallback behavior Internet-direct is best-effort and requires an actually reachable public -address, such as a publicly routed host or an explicitly configured network. +address from a host network interface. The mod does not open a public Minecraft listener, configure UPnP, or use a self-hosted libp2p relay. @@ -164,7 +160,7 @@ 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 + 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 @@ -185,8 +181,11 @@ The nested payload must include ## Real Prism matrix -Use the opt-in `PrismFriendJoinE2ETest` harness for each of the six packaged -artifacts. Run it with `--rerun-tasks`: its live environment variables are +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 diff --git a/docs/connect-share.md b/docs/connect-share.md index 0982d7ca4..b278b0cc5 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -12,8 +12,8 @@ IP addresses or create a new link for every world. 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 to - join**. The host gets an in-game notification and can allow or deny it. +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 @@ -97,5 +97,6 @@ publication additionally requires the repository's project IDs and publisher credentials; the workflow fails closed when they are absent. Forge and NeoForge reuse the loader-neutral Kotlin core and version-specific -Minecraft UI/bridge adapters. Their packaged artifacts pass the same real -two-client Prism host/join gate as the Fabric artifacts. +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). From 781de2163e0c0395bd8911e671eb4a504e347ffe Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 12:12:32 +0200 Subject: [PATCH 151/188] feat(share): deliver polished social UX --- .../skills/connect-share-prism-e2e/SKILL.md | 33 +- share/AGENTS.md | 5 + .../v1_20_1/mixin/PauseScreenMixin.java | 52 +- .../v1_20_1/mixin/TitleScreenMixin.java | 25 +- .../fabric/v1_20_1/BlockedFriendsScreen.kt | 122 +++- .../v1_20_1/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v1_20_1/EndpointIdentityScreen.kt | 178 +++-- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 645 ++++++++++++------ .../fabric/v1_20_1/SharePrivacyScreen.kt | 83 ++- .../share/fabric/v1_20_1/ShareSetupScreen.kt | 199 ++++-- .../share/fabric/v1_20_1/ShareStatusScreen.kt | 320 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../fabric/v1_20_1/Fabric12111ArtifactTest.kt | 10 +- .../v1_21_1/mixin/PauseScreenMixin.java | 52 +- .../v1_21_1/mixin/TitleScreenMixin.java | 25 +- .../fabric/v1_21_1/BlockedFriendsScreen.kt | 122 +++- .../v1_21_1/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v1_21_1/EndpointIdentityScreen.kt | 178 +++-- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 628 +++++++++++------ .../fabric/v1_21_1/SharePrivacyScreen.kt | 80 ++- .../share/fabric/v1_21_1/ShareSetupScreen.kt | 187 +++-- .../share/fabric/v1_21_1/ShareStatusScreen.kt | 320 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../fabric/v1_21_1/Fabric12111ArtifactTest.kt | 10 +- .../v1_21_11/mixin/PauseScreenMixin.java | 52 +- .../v1_21_11/mixin/TitleScreenMixin.java | 25 +- .../fabric/v1_21_11/BlockedFriendsScreen.kt | 124 +++- .../v1_21_11/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v1_21_11/EndpointIdentityScreen.kt | 178 +++-- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 635 +++++++++++------ .../fabric/v1_21_11/SharePrivacyScreen.kt | 80 ++- .../share/fabric/v1_21_11/ShareSetupScreen.kt | 181 +++-- .../fabric/v1_21_11/ShareStatusScreen.kt | 322 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../v1_21_11/Fabric12111ArtifactTest.kt | 10 +- .../fabric/v26_2/mixin/PauseScreenMixin.java | 52 +- .../fabric/v26_2/mixin/TitleScreenMixin.java | 25 +- .../fabric/v26_2/BlockedFriendsScreen.kt | 124 +++- .../v26_2/CompatibilityMismatchScreen.kt | 123 ++-- .../fabric/v26_2/EndpointIdentityScreen.kt | 172 +++-- .../share/fabric/v26_2/ShareJoinScreen.kt | 632 +++++++++++------ .../share/fabric/v26_2/SharePrivacyScreen.kt | 80 ++- .../share/fabric/v26_2/ShareSetupScreen.kt | 181 +++-- .../share/fabric/v26_2/ShareStatusScreen.kt | 322 ++++++--- .../assets/connect-share/lang/de_de.json | 81 ++- .../assets/connect-share/lang/en_us.json | 99 ++- .../fabric/v26_2/Fabric262ArtifactTest.kt | 10 +- .../share/fabric/ConnectShareClient.kt | 22 + .../share/fabric/ui/AdaptiveShareLayout.kt | 109 +++ .../fabric/ui/ShareScreenPresentation.kt | 201 ++++++ .../share/fabric/PrismFriendJoinE2ETest.kt | 39 +- .../fabric/ui/AdaptiveShareLayoutTest.kt | 48 ++ .../fabric/ui/ShareScreenPresentationTest.kt | 192 ++++++ 56 files changed, 6167 insertions(+), 2135 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index dccfc1087..8e5119414 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -73,6 +73,7 @@ Start it after the host world is ready: LIVE_DATA= \ LIVE_PORT_FILE= \ LIVE_HOST_LOG= \ +LIVE_GUEST_LOG= \ LIVE_PLAYER_NAME= \ ./gradlew :share:fabric-common:test \ --tests '*PrismFriendJoinE2ETest*' --no-parallel @@ -86,12 +87,42 @@ The test must remain running while the external guest uses the port written to 3. A dedicated direct proxy answers a real Minecraft status probe. 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. +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. + +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 diff --git a/share/AGENTS.md b/share/AGENTS.md index 23afe60f5..555c5ae53 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -128,3 +128,8 @@ redesigned for Kotlin. and name it from the loader-specific mixin config. Forge and NeoForge client resources need a compatible `pack.mcmeta`, otherwise startup can stop at a resource-pack warning before quick-play E2E begins. +- Visual QA is keyboard-only at both the normal Prism window size and 640x400. + A focused Minecraft `EditBox` hides its hint, so every input needs a + persistent label; split pause-menu buttons must keep copy within their + 100-pixel logical width. The repository Prism skill owns the capture and + focus-order procedure. 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 index c1799c89d..fb8bc3091 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-1.20.1/src/main/java/com/minekube/connect/share/fabric/v1_20_1/mixin/TitleScreenMixin.java index 34bc29bed..26f3ecca3 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index 2ff433f56..f7ed4235d 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,27 +14,39 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, ), ) @@ -38,33 +54,89 @@ class BlockedFriendsScreen( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index 06a07a2f3..f9c931c72 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v1_20_1 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft!!.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft!!.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft!!.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/EndpointIdentityScreen.kt index 09b96e09e..954821569 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,49 +157,66 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + 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) + minecraft!!.setScreen(parent) } private fun confirmReset() { - minecraft?.setScreen( + minecraft!!.setScreen( ConfirmScreen( { confirmed -> if (confirmed) { viewModel.resetIdentity() } - minecraft?.setScreen(this) + minecraft!!.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt index be7c3dc63..3538520a3 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft!!.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,18 +224,23 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20).build().apply { - setTooltip(pageTooltip) - }, + }.bounds(layout.contentX, layout.headerY, 24, 20) + .tooltip(pageTooltip) + .build(), ) previous.active = page.hasPrevious val next = addRenderableWidget( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20).build().apply { - setTooltip(pageTooltip) - }, + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) + .tooltip(pageTooltip) + .build(), ) next.active = page.hasNext } @@ -223,7 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -232,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -249,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -269,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -283,7 +336,7 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, ), @@ -293,27 +346,40 @@ class ShareJoinScreen( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -327,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -339,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -448,48 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - ObservableCheckbox( - width / 2 - 155, - 112, - 310, + 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.join.offline"), - offlineSelected, - { selected -> offlineSelected = selected }, + Component.translatable("connect_share.friends.name"), ).apply { - setTooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.offline.tooltip", - ), - ), - ) + setMaxLength(64) + setHint(Component.translatable("connect_share.friends.name_hint")) + setValue(nameValue) + setResponder { + nameValue = it + refresh() + } }, ) - internetDirect = addRenderableWidget( - ObservableCheckbox( - width / 2 - 155, - 134, - 310, + 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, - Component.translatable("connect_share.join.internet"), - internetSelected, - { selected -> internetSelected = selected }, - ).apply { - setTooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - }, + ).build(), ) safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), 160), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -500,23 +606,109 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -528,21 +720,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -556,9 +761,9 @@ class ShareJoinScreen( ) val notify = addRenderableWidget( ObservableCheckbox( - width / 2 - 155, - 82, - 310, + layout.contentX, + layout.bodyTop + 28, + layout.contentWidth, 20, Component.translatable("connect_share.friends.notify"), friend.permissions.notifyWhenOnline, @@ -575,42 +780,44 @@ class ShareJoinScreen( ).withInitialValue(accessPolicy) .withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, ) val shareWorlds = addRenderableWidget( ObservableCheckbox( - width / 2 - 155, - 104, - 310, + layout.contentX, + layout.bodyTop + 50, + layout.contentWidth, 20, Component.translatable("connect_share.friends.share_worlds"), friend.permissions.canSeeMyWorlds, ), ) - val guestInternetDirect = addRenderableWidget( - ObservableCheckbox( - width / 2 - 155, - 126, - 310, - 20, - Component.translatable( - "connect_share.friends.internet_direct", - ), - friend.internetDirectGuestOptIn, - ), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -623,7 +830,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -639,7 +846,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -647,24 +859,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -672,7 +895,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -695,7 +919,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -715,13 +944,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1062,6 +1301,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1070,59 +1310,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1171,17 +1396,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1202,7 +1429,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index ea50255ba..c49e7a8c5 100644 --- 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 @@ -1,8 +1,12 @@ 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 @@ -14,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -44,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -64,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -93,11 +135,12 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( ObservableCheckbox( - width / 2 - 155, + layout.contentX, y, - 310, + layout.contentWidth, 20, Component.translatable("connect_share.privacy.$key"), selected, @@ -105,4 +148,16 @@ class SharePrivacyScreen( ), ) } + + 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 index b8fd99d56..3156e7ac4 100644 --- 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 @@ -2,8 +2,14 @@ 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 @@ -15,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft!!.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.allowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, - ).withValues(ShareGameMode.entries) - .withInitialValue(current.options.gameMode) + ).withInitialValue(current.options.gameMode) + .withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -48,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -58,28 +160,27 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.builder( { guests: Int -> Component.literal(guests.toString()) }, - ).withValues((1..16).toList()) - .withInitialValue(current.options.maxGuests) + ).withInitialValue(current.options.maxGuests) + .withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, ) addRenderableWidget( ObservableCheckbox( - width / 2 - 155, - 126, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.setup.internet"), current.options.allowInternetDirect, - { allowed -> - viewModel.setAllowInternetDirect(allowed) - }, - ).apply { + ) { allowed -> + viewModel.setAllowInternetDirect(allowed) + }.apply { setTooltip( Tooltip.create( Component.translatable( @@ -89,35 +190,6 @@ class ShareSetupScreen( ) }, ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft!!.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -135,11 +207,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareStatusScreen.kt index c27494d70..5ba936a9c 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft!!.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ), + ) { + invitation?.let { + minecraft!!.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft!!.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, ), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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")) { + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { viewModel.stop() minecraft!!.setScreen(parent) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( minecraft!!.setScreen(parent) } - 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 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.20.1/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index 4bc15a011..23aa3bf1b 100644 --- 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 @@ -58,12 +58,12 @@ class Fabric1201ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -75,11 +75,11 @@ class Fabric1201ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index da981f25c..54cfe3580 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-1.21.1/src/main/java/com/minekube/connect/share/fabric/v1_21_1/mixin/TitleScreenMixin.java index a89542e96..e64bbc930 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index dec11f361..e5f92d7a6 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,27 +14,39 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, ), ) @@ -38,33 +54,89 @@ class BlockedFriendsScreen( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index 3570aa65e..7de1d9ffd 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v1_21_1 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft!!.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft!!.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft!!.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/EndpointIdentityScreen.kt index ddc95d129..3dd629db1 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,49 +157,66 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + 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) + minecraft!!.setScreen(parent) } private fun confirmReset() { - minecraft?.setScreen( + minecraft!!.setScreen( ConfirmScreen( { confirmed -> if (confirmed) { viewModel.resetIdentity() } - minecraft?.setScreen(this) + minecraft!!.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt index bbb753321..999a4e690 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft!!.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,7 +224,7 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20) + }.bounds(layout.contentX, layout.headerY, 24, 20) .tooltip(pageTooltip) .build(), ) @@ -213,7 +233,12 @@ class ShareJoinScreen( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20) + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) .tooltip(pageTooltip) .build(), ) @@ -223,7 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -232,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -249,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -269,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -283,7 +336,7 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, ), @@ -293,27 +346,40 @@ class ShareJoinScreen( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -327,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -339,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -448,46 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.join.offline"), + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), font, - ).pos(width / 2 - 155, 112) - .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"), + nameBox = addRenderableWidget( + EditBox( font, - ).pos(width / 2 - 155, 134) - .selected(internetSelected) - .onValueChange { _, selected -> - internetSelected = selected + 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() } - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - .build(), + }, + ) + 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( - centered(Component.literal(message), 160), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -498,23 +606,107 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -526,21 +718,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -556,7 +761,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(width / 2 - 155, 82) + ).pos(layout.contentX, layout.bodyTop + 28) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -571,9 +776,9 @@ class ShareJoinScreen( ).withInitialValue(accessPolicy) .withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, @@ -582,27 +787,31 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(width / 2 - 155, 104) + ).pos(layout.contentX, layout.bodyTop + 50) .selected(friend.permissions.canSeeMyWorlds) .build(), ) - val guestInternetDirect = addRenderableWidget( - Checkbox.builder( - Component.translatable( - "connect_share.friends.internet_direct", - ), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.internetDirectGuestOptIn) - .build(), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -615,7 +824,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -631,7 +840,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -639,24 +853,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -664,7 +889,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -687,7 +913,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -707,13 +938,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1004,7 +1245,6 @@ class ShareJoinScreen( } private fun connect(target: GuestJoinTarget) { - val client = checkNotNull(minecraft) val address = when (target) { is GuestJoinTarget.Connect -> ServerAddress.parseString(target.publicAddress) @@ -1040,7 +1280,7 @@ class ShareJoinScreen( } ConnectScreen.startConnecting( parent, - client, + checkNotNull(minecraft), address, data, false, @@ -1055,6 +1295,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1063,59 +1304,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1164,17 +1390,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1195,7 +1423,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index ca93228a5..7816eed26 100644 --- 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 @@ -1,9 +1,12 @@ 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 @@ -15,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -45,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -65,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -94,14 +135,27 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.privacy.$key"), font, - ).pos(width / 2 - 155, y) + ).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 index 5453b9e9d..a75ef3837 100644 --- 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 @@ -2,9 +2,14 @@ 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 @@ -16,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft!!.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.isAllowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, - ).withValues(ShareGameMode.entries) - .withInitialValue(current.options.gameMode) + ).withInitialValue(current.options.gameMode) + .withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -49,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -59,12 +160,12 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.builder( { guests: Int -> Component.literal(guests.toString()) }, - ).withValues((1..16).toList()) - .withInitialValue(current.options.maxGuests) + ).withInitialValue(current.options.maxGuests) + .withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, @@ -73,7 +174,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 126) + ).pos(layout.contentX, layout.bodyTop + 74) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,35 +188,6 @@ class ShareSetupScreen( ) .build(), ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft!!.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -133,11 +205,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareStatusScreen.kt index 3e7b03c02..623f19c21 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft!!.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft!!.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ), + ) { + invitation?.let { + minecraft!!.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft!!.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft!!.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, ), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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")) { + Button.builder( + Component.translatable("connect_share.status.stop"), + ) { viewModel.stop() minecraft!!.setScreen(parent) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( minecraft!!.setScreen(parent) } - 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 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.21.1/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index 1d972d435..a1181d1d9 100644 --- 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 @@ -27,12 +27,12 @@ class Fabric1211ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -44,11 +44,11 @@ class Fabric1211ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index af243eb23..4aaa1fa4c 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-1.21.11/src/main/java/com/minekube/connect/share/fabric/v1_21_11/mixin/TitleScreenMixin.java index 05db86ab3..dd6842a68 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index e048d7291..ca424d4e5 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,61 +14,129 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, - ), + ).setMaxWidth(layout.contentWidth - buttonWidth - 6), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index be256e22a..dcc971a29 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v1_21_11 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/EndpointIdentityScreen.kt index e00c80162..cc26e338f 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,49 +157,66 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + 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) + minecraft.setScreen(parent) } private fun confirmReset() { - minecraft?.setScreen( + minecraft.setScreen( ConfirmScreen( { confirmed -> if (confirmed) { viewModel.resetIdentity() } - minecraft?.setScreen(this) + minecraft.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt index c4d1f6397..ccde70a2f 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,7 +224,7 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20) + }.bounds(layout.contentX, layout.headerY, 24, 20) .tooltip(pageTooltip) .build(), ) @@ -213,7 +233,12 @@ class ShareJoinScreen( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20) + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) .tooltip(pageTooltip) .build(), ) @@ -223,8 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -233,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -250,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -270,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -284,37 +336,50 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, - ).setMaxWidth(174), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -328,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -340,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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(242 - actionWidth), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( - Component.translatable( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -449,47 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.join.offline"), + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), font, - ).pos(width / 2 - 155, 112) - .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"), + nameBox = addRenderableWidget( + EditBox( font, - ).pos(width / 2 - 155, 134) - .selected(internetSelected) - .onValueChange { _, selected -> - internetSelected = selected + 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() } - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - .build(), + }, + ) + 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( - centered(Component.literal(message), 160) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -500,23 +606,107 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -528,21 +718,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -558,7 +761,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(width / 2 - 155, 82) + ).pos(layout.contentX, layout.bodyTop + 28) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -573,9 +776,9 @@ class ShareJoinScreen( accessPolicy, ).withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, @@ -584,28 +787,31 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(width / 2 - 155, 104) + ).pos(layout.contentX, layout.bodyTop + 50) .selected(friend.permissions.canSeeMyWorlds) .build(), ) - val guestInternetDirect = addRenderableWidget( - Checkbox.builder( - Component.translatable( - "connect_share.friends.internet_direct", - ), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.internetDirectGuestOptIn) - .build(), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176) - .setMaxWidth(CONTENT_WIDTH), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -618,7 +824,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -634,7 +840,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -642,24 +853,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -667,7 +889,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -690,7 +913,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -710,13 +938,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1007,7 +1245,6 @@ class ShareJoinScreen( } private fun connect(target: GuestJoinTarget) { - val client = minecraft val address = when (target) { is GuestJoinTarget.Connect -> ServerAddress.parseString(target.publicAddress) @@ -1043,7 +1280,7 @@ class ShareJoinScreen( } ConnectScreen.startConnecting( parent, - client, + minecraft, address, data, false, @@ -1058,6 +1295,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1066,59 +1304,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1167,17 +1390,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1198,7 +1423,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index f211e2d40..61cd7199b 100644 --- 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 @@ -1,9 +1,12 @@ 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 @@ -15,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -45,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -65,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -94,14 +135,27 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.privacy.$key"), font, - ).pos(width / 2 - 155, y) + ).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 index 3bf49f46b..56cc7f524 100644 --- 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 @@ -2,9 +2,14 @@ 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 @@ -16,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.isAllowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, - ).setMaxWidth(CONTENT_WIDTH), + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, current.options.gameMode, ).withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -49,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -62,9 +163,9 @@ class ShareSetupScreen( current.options.maxGuests, ).withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, @@ -73,7 +174,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 126) + ).pos(layout.contentX, layout.bodyTop + 74) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,35 +188,6 @@ class ShareSetupScreen( ) .build(), ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ).setMaxWidth(CONTENT_WIDTH), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -133,11 +205,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareStatusScreen.kt index 8f479f162..88f15c66e 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ).setMaxWidth(CONTENT_WIDTH), + ) { + invitation?.let { + minecraft.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ).setMaxWidth(CONTENT_WIDTH), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, - ).setMaxWidth(202), + ).setMaxWidth(labelWidth), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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.status.stop")) { + 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(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( minecraft.setScreen(parent) } - 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 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-1.21.11/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index fa1eb77d7..7c02088e5 100644 --- 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 @@ -27,12 +27,12 @@ class Fabric12111ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -44,11 +44,11 @@ class Fabric12111ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index f873b5b30..32755099b 100644 --- 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 @@ -19,37 +19,65 @@ 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$addButton(CallbackInfo ci) { + private void connectShare$addButtons(CallbackInfo ci) { Minecraft client = Minecraft.getInstance(); if (!showPauseMenu - || !client.hasSingleplayerServer() || disconnectButton == null || !ConnectShareClient.isInstalled()) { return; } - int shareY = disconnectButton.getY(); - disconnectButton.setY(shareY + 24); - connectShareButton = addRenderableWidget( - Button.builder( - Component.translatable( - ConnectShareClient.pauseButtonTranslationKey()), - button -> ConnectShareClient.openPauseScreen(this)) - .bounds(disconnectButton.getX(), shareY, 204, 20) - .build()); + int rowX = disconnectButton.getX(); + int rowY = disconnectButton.getY(); + disconnectButton.setY(rowY + 24); + 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()); + } } @Inject(method = "tick", at = @At("TAIL")) - private void connectShare$refreshButton(CallbackInfo ci) { + 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/TitleScreenMixin.java b/share/fabric-26.2/src/main/java/com/minekube/connect/share/fabric/v26_2/mixin/TitleScreenMixin.java index 6b7d83b8c..bfd176ae7 100644 --- 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 @@ -5,12 +5,15 @@ 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); } @@ -20,11 +23,27 @@ protected TitleScreenMixin(Component title) { if (!ConnectShareClient.isInstalled()) { return; } - addRenderableWidget( + connectShare$friendsButton = addRenderableWidget( Button.builder( - Component.translatable("connect_share.menu.join"), + connectShare$friendsLabel(), button -> ConnectShareClient.openJoinScreen(this)) - .bounds(width - 106, 4, 102, 20) + .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 index 25578a66f..25c4e968e 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -10,61 +14,129 @@ 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( - StringWidget( - width / 2 - font.width(title) / 2, - 16, - font.width(title), - 20, - title, + 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), ) - friends.state.value.blocked.take(5).forEachIndexed { index, blocked -> - val y = 48 + index * 26 + + page.items.forEachIndexed { index, identity -> + val y = layout.rowY(index) + val buttonWidth = 88 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + layout.contentWidth - buttonWidth - 6, 20, - Component.literal(blocked.displayName), + Component.literal(identity.displayName), font, - ), + ).setMaxWidth(layout.contentWidth - buttonWidth - 6), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.privacy.unblock"), ) { - friends.unblock(blocked.peerId) + friends.unblock(identity.peerId) rebuildWidgets() - }.bounds(width / 2 + 51, y, 104, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } - if (friends.state.value.blocked.isEmpty()) { - val empty = Component.translatable( - "connect_share.privacy.blocked_empty", + + if (blocked.isEmpty()) { + addRenderableWidget( + centered( + Component.translatable("connect_share.privacy.blocked_empty") + .withStyle(ChatFormatting.GRAY), + layout.rowsTop + 18, + ), ) + } else { addRenderableWidget( - StringWidget( - width / 2 - font.width(empty) / 2, - 70, - font.width(empty), - 20, - empty, - font, + 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(width / 2 - 75, height - 28, 150, 20).build(), + .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 index 0fb980014..29ed1e988 100644 --- 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 @@ -1,9 +1,12 @@ package com.minekube.connect.share.fabric.v26_2 import com.minekube.connect.share.fabric.FriendJoinAttemptFailure -import com.minekube.connect.share.friend.CompatibilityDifference +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 @@ -16,30 +19,52 @@ class CompatibilityMismatchScreen( private var packCopied = false override fun init() { + val layout = AdaptiveShareLayout.form(width, height, 1) addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 20, - title, - font, - ).setMaxWidth(310).setCentered(true), - ) - addRenderableWidget( - MultiLineTextWidget( - width / 2 - 155, - 48, - Component.literal(failure.safeMessage), - font, - ).setMaxWidth(310).setCentered(true), + centered(title.copy().withStyle(ChatFormatting.YELLOW), layout.headerY), ) addRenderableWidget( MultiLineTextWidget( - width / 2 - 155, - 78, - Component.literal(details()), + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.compatibility.description"), font, - ).setMaxWidth(310), + ).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( @@ -54,9 +79,15 @@ class CompatibilityMismatchScreen( minecraft.keyboardHandler.setClipboard(pack.url) packCopied = true rebuildWidgets() - }.bounds(width / 2 - 155, height - 76, 310, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) } + val actionY = layout.footerTop + 24 if (failure.canTryAnyway) { addRenderableWidget( Button.builder( @@ -66,15 +97,28 @@ class CompatibilityMismatchScreen( ) { minecraft.gui.setScreen(parent) tryAnyway() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + actionY, + layout.halfButtonWidth, + 20, + ).build(), ) } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } .bounds( - if (failure.canTryAnyway) width / 2 + 5 else width / 2 - 75, - height - 52, - 150, + if (failure.canTryAnyway) { + layout.contentX + layout.halfButtonWidth + 6 + } else { + layout.contentX + }, + actionY, + if (failure.canTryAnyway) { + layout.halfButtonWidth + } else { + layout.contentWidth + }, 20, ).build(), ) @@ -84,26 +128,15 @@ class CompatibilityMismatchScreen( minecraft.gui.setScreen(parent) } - private fun details(): String = failure.report.differences - .take(MAX_VISIBLE_DIFFERENCES) - .joinToString("\n") { difference -> - when (difference) { - is CompatibilityDifference.MinecraftVersion -> - "Minecraft: you ${difference.local}, host ${difference.remote}" - is CompatibilityDifference.Loader -> - "Loader: you ${difference.local.name.lowercase()}, " + - "host ${difference.remote.name.lowercase()}" - is CompatibilityDifference.MissingLocal -> - "Install ${difference.modId} ${difference.remoteVersion}" - is CompatibilityDifference.MissingRemote -> - "Host is missing ${difference.modId} ${difference.localVersion}" - is CompatibilityDifference.ModVersion -> - "${difference.modId}: you ${difference.local}, " + - "host ${difference.remote}" - } - } - - private companion object { - const val MAX_VISIBLE_DIFFERENCES = 5 + 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/EndpointIdentityScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/EndpointIdentityScreen.kt index 3d684381a..32c93b7a6 100644 --- 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 @@ -1,10 +1,13 @@ 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.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 @@ -24,76 +27,127 @@ class EndpointIdentityScreen( override fun init() { val state = viewModel.state.value - fingerprint = state.copy(importDraft = state.importDraft.copy(token = "")).hashCode() - addRenderableWidget(centered(title, 18)) + fingerprint = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + val layout = AdaptiveShareLayout.form(width, height, 2) - val identity = state.identity addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.current", - identity?.endpoint ?: "…", - ), - 38, - ), + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), ) addRenderableWidget( - centered( - Component.translatable( - "connect_share.identity.sources", - identity?.endpointSource?.displayName() ?: "…", - identity?.tokenSource?.displayName() ?: "…", - ), - 52, - ), + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, + Component.translatable("connect_share.identity.description"), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), ) addRenderableWidget( - centered(Component.translatable("connect_share.identity.endpoint"), 72), + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.endpoint"), + font, + ), ) endpointBox = EditBox( font, - width / 2 - 100, - 84, - 200, + 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( - centered(Component.translatable("connect_share.identity.token"), 110), + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.identity.token"), + font, + ), ) tokenBox = EditBox( font, - width / 2 - 100, - 122, - 200, + 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) + 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, + Component.literal(safeMessage) + .withStyle(ChatFormatting.YELLOW), + font, + ).setMaxWidth(layout.contentWidth).setCentered(true), + ) + } + val save = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.save")) { + Button.builder( + Component.translatable("connect_share.identity.save"), + ) { viewModel.importIdentity() - }.bounds(width / 2 - 155, 150, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) val choose = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.choose_file")) { + Button.builder( + Component.translatable("connect_share.identity.choose_file"), + ) { chooseTokenFile()?.let(viewModel::importTokenFile) - }.bounds(width / 2 + 5, 150, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) save.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && @@ -103,29 +157,42 @@ class EndpointIdentityScreen( !state.operationInProgress val reset = addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.reset")) { + Button.builder( + Component.translatable("connect_share.identity.reset"), + ) { confirmReset() - }.bounds(width / 2 - 100, 178, 200, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) reset.active = state.importDraft.endpointEditable && state.importDraft.tokenEditable && !state.operationInProgress - - state.safeMessage?.let { safeMessage -> - addRenderableWidget(centered(Component.literal(safeMessage), 204)) - } addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 100, height - 28, 200, 20) - .build(), + .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) { + val next = state.copy( + importDraft = state.importDraft.copy(token = ""), + ).hashCode() + if ( + next != fingerprint || + state.importDraft.token.isEmpty() && + tokenBox?.value?.isNotEmpty() == true + ) { rebuildWidgets() } } @@ -144,8 +211,12 @@ class EndpointIdentityScreen( } minecraft.gui.setScreen(this) }, - Component.translatable("connect_share.identity.reset_confirm.title"), - Component.translatable("connect_share.identity.reset_confirm.message"), + Component.translatable( + "connect_share.identity.reset_confirm.title", + ), + Component.translatable( + "connect_share.identity.reset_confirm.message", + ), ), ) } @@ -163,9 +234,18 @@ class EndpointIdentityScreen( private fun centered(message: Component, y: Int): StringWidget { val textWidth = font.width(message) - return StringWidget(width / 2 - textWidth / 2, y, textWidth, 9, message, font) + return StringWidget( + width / 2 - textWidth / 2, + y, + textWidth, + 11, + message, + font, + ) } } -private fun CredentialSource.displayName(): String = - name.lowercase().replaceFirstChar(Char::titlecase) +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/ShareJoinScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt index 89780d586..7db8edddf 100644 --- 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 @@ -12,10 +12,19 @@ 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.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.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 @@ -39,6 +48,7 @@ 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 @@ -97,6 +107,7 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> buildFriends() Mode.ADD -> buildAddFriend() + Mode.CONNECTION_OPTIONS -> buildConnectionOptions() Mode.MANAGE -> buildManageFriend() } } @@ -126,9 +137,14 @@ class ShareJoinScreen( when (mode) { Mode.FRIENDS -> minecraft.gui.setScreen(parent) Mode.ADD, + Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = Mode.FRIENDS + mode = if (mode == Mode.CONNECTION_OPTIONS) { + Mode.ADD + } else { + Mode.FRIENDS + } selectedPeerId = null safeMessage = null rebuildWidgets() @@ -143,20 +159,23 @@ class ShareJoinScreen( } 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"), - 16, + Component.translatable("connect_share.friends.title") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( - centeredWrapped( - Component.translatable("connect_share.friends.description"), - 34, + centered( + friendsSummary(overview), + layout.subtitleY, ), ) - val state = friends.state.value val relationships = buildList { state.incomingRequests.forEach { add(RelationshipRow.Incoming(it)) @@ -168,28 +187,29 @@ class ShareJoinScreen( } val page = relationships.page( offset = relationshipOffset, - size = MAX_VISIBLE_RELATIONSHIPS, + size = layout.visibleRows, ) relationshipOffset = page.offset if (relationships.isEmpty()) { addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.empty"), - 82, + layout.rowsTop + 24, + layout.contentWidth, ), ) } page.items.forEachIndexed { index, relationship -> - val y = 58 + index * 26 + val y = layout.rowY(index) when (relationship) { is RelationshipRow.Incoming -> - addIncomingRow(relationship.request, y) + addIncomingRow(relationship.request, y, layout) is RelationshipRow.Outgoing -> - addOutgoingRow(relationship.request, y) + addOutgoingRow(relationship.request, y, layout) is RelationshipRow.Friend -> - addFriendRow(relationship.friend, y) + addFriendRow(relationship.friend, y, layout) } } if (page.pageCount > 1) { @@ -204,7 +224,7 @@ class ShareJoinScreen( Button.builder(Component.literal("‹")) { relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() - }.bounds(width / 2 - 155, 14, 24, 20) + }.bounds(layout.contentX, layout.headerY, 24, 20) .tooltip(pageTooltip) .build(), ) @@ -213,7 +233,12 @@ class ShareJoinScreen( Button.builder(Component.literal("›")) { relationshipOffset = page.nextOffset ?: page.offset rebuildWidgets() - }.bounds(width / 2 + 131, 14, 24, 20) + }.bounds( + layout.contentX + layout.contentWidth - 24, + layout.headerY, + 24, + 20, + ) .tooltip(pageTooltip) .build(), ) @@ -223,8 +248,12 @@ class ShareJoinScreen( safeMessage().let { message -> if (message != null) { addRenderableWidget( - centered(Component.literal(message), height - 76) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message) + .withStyle(ChatFormatting.YELLOW), + layout.messageY, + layout.contentWidth, + ), ) } } @@ -233,7 +262,12 @@ class ShareJoinScreen( Component.translatable(friendLinkState.translationKey), ) { copyMyFriendLink() - }.bounds(width / 2 - 155, height - 52, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ) .tooltip( Tooltip.create( Component.translatable( @@ -250,19 +284,34 @@ class ShareJoinScreen( mode = Mode.ADD safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.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(width / 2 - 155, height - 28, 150, 20) + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ) .build(), ) } @@ -270,12 +319,15 @@ class ShareJoinScreen( private fun addIncomingRow( request: IncomingFriendRequestSummary, y: Int, + layout: FriendsScreenLayout, ) { + val buttonWidth = 58 + val textWidth = layout.contentWidth - buttonWidth * 2 - 12 addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, Component.translatable( if (request.purpose == com.minekube.connect.share.admission.AdmissionPurpose.FRIEND) { @@ -284,37 +336,50 @@ class ShareJoinScreen( "connect_share.friends.incoming_join_request" }, request.displayName, - request.ingress.displayName(), + friendlyIngress(request.ingress), ), font, - ).setMaxWidth(174), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( Component.translatable("connect_share.status.allow"), ) { ConnectShareClient.viewModel().allow(request.requestId) - }.bounds(width / 2 + 23, y, 62, 20).build(), + }.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(width / 2 + 89, y, 66, 20).build(), + }.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( - width / 2 - 155, + layout.contentX, y, - 174, + textWidth, 20, outgoingRequestLabel(request.displayName, deliveryState), font, @@ -328,7 +393,12 @@ class ShareJoinScreen( ), ) { deliverOutgoing(request.peerId) - }.bounds(width / 2 + 23, y, 62, 20).build().apply { + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth * 2 - 4, + y + 2, + buttonWidth, + 20, + ).build().apply { active = deliveryState == null || deliveryState == RequestDeliveryState.FAILED }, @@ -340,103 +410,129 @@ class ShareJoinScreen( ), ) { cancelOutgoing(request.peerId) - }.bounds(width / 2 + 89, y, 66, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y + 2, + buttonWidth, + 20, + ).build(), ) } - private fun addFriendRow(friend: FriendSummary, y: Int) { - val actionWidth = 86 + 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( - width / 2 - 155, + layout.contentX, y, - 242 - actionWidth, - 20, - friendLabel(friend), + 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(242 - actionWidth), + ).setMaxWidth(textWidth), ) addRenderableWidget( Button.builder( - Component.translatable( - when { - friend.canRequestJoin -> - "connect_share.friends.request_join" - friend.canJoinNow -> "connect_share.join.join" - friend.following -> - "connect_share.friends.cancel_follow" - else -> "connect_share.friends.follow" - }, - ), + Component.translatable(presentation.action.translationKey), ) { - when { - friend.canRequestJoin -> requestToJoin(friend.peerId) - friend.canJoinNow -> joinSaved(friend.peerId) - friend.following -> friends.cancelFollow(friend.peerId) - else -> friends.follow(friend.peerId) + 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(width / 2 + 1, y, 86, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - actionWidth - manageWidth - 4, + y + 2, + actionWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( - Component.translatable("connect_share.friends.manage"), + Component.literal("…"), ) { selectedPeerId = friend.peerId nameValue = friend.displayName mode = Mode.MANAGE safeMessage = null rebuildWidgets() - }.bounds(width / 2 + 91, y, 64, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - manageWidth, + y + 2, + manageWidth, + 20, + ).tooltip( + Tooltip.create( + 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"), - 16, + Component.translatable("connect_share.friends.add") + .withStyle(ChatFormatting.BOLD), + layout.headerY, ), ) addRenderableWidget( centeredWrapped( Component.translatable("connect_share.friends.add_description"), - 34, + layout.subtitleY, + layout.contentWidth, ), ) - nameBox = addRenderableWidget( - EditBox( + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.link"), font, - width / 2 - 155, - 58, - 310, - 20, - Component.translatable("connect_share.friends.name"), - ).apply { - setMaxLength(64) - setHint(Component.translatable("connect_share.friends.name_hint")) - setValue(nameValue) - setResponder { - nameValue = it - refresh() - } - }, + ), ) invitationBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 84, - 310, + 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", - ), - ) + setHint(Component.translatable("connect_share.join.invitation_hint")) setValue(invitationValue) setResponder { invitationValue = it @@ -449,47 +545,57 @@ class ShareJoinScreen( } }, ) - offlineMode = addRenderableWidget( - Checkbox.builder( - Component.translatable("connect_share.join.offline"), + addRenderableWidget( + StringWidget( + layout.contentX, + layout.bodyTop + 38, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), font, - ).pos(width / 2 - 155, 112) - .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"), + nameBox = addRenderableWidget( + EditBox( font, - ).pos(width / 2 - 155, 134) - .selected(internetSelected) - .onValueChange { _, selected -> - internetSelected = selected + 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() } - .tooltip( - Tooltip.create( - Component.translatable( - "connect_share.join.internet.tooltip", - ), - ), - ) - .build(), + }, + ) + 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( - centered(Component.literal(message), 160) - .setMaxWidth(CONTENT_WIDTH), + centeredWrapped( + Component.literal(message).withStyle(ChatFormatting.YELLOW), + layout.footerTop - 16, + layout.contentWidth, + ), ) } } @@ -500,23 +606,107 @@ class ShareJoinScreen( ), ) { createFriendRequest() - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.contentWidth, + 20, + ).build(), ) secondaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.join_once"), ) { joinInvitation() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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) { @@ -528,21 +718,34 @@ class ShareJoinScreen( buildRemoveFriendConfirmation(friend) return } + val layout = AdaptiveShareLayout.form(width, height, 5) + var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( Component.translatable( "connect_share.friends.manage_title", friend.displayName, + ).withStyle(ChatFormatting.BOLD), + layout.headerY, + ), + ) + addRenderableWidget( + centeredWrapped( + safeMessage()?.let { + Component.literal(it).withStyle(ChatFormatting.YELLOW) + } ?: friendStatus(friend).copy().withStyle( + friend.presentation().tone.color(), ), - 16, + layout.subtitleY, + layout.contentWidth, ), ) nameBox = addRenderableWidget( EditBox( font, - width / 2 - 155, - 50, - 310, + layout.contentX, + layout.bodyTop, + layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), ).apply { @@ -558,7 +761,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(width / 2 - 155, 82) + ).pos(layout.contentX, layout.bodyTop + 28) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -573,9 +776,9 @@ class ShareJoinScreen( accessPolicy, ).withValues(FriendAccessPolicy.entries) .create( - width / 2 - 155, - 148, - 310, + layout.contentX, + layout.bodyTop + 74, + layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), ) { _, selected -> accessPolicy = selected }, @@ -584,28 +787,31 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(width / 2 - 155, 104) + ).pos(layout.contentX, layout.bodyTop + 50) .selected(friend.permissions.canSeeMyWorlds) .build(), ) - val guestInternetDirect = addRenderableWidget( - Checkbox.builder( - Component.translatable( - "connect_share.friends.internet_direct", - ), - font, - ).pos(width / 2 - 155, 126) - .selected(friend.internetDirectGuestOptIn) - .build(), + addRenderableWidget( + CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) + .create( + layout.contentX, + layout.bodyTop + 100, + 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", + ), + ), + ) + }, ) - safeMessage().let { message -> - if (message != null) { - addRenderableWidget( - centered(Component.literal(message), 176) - .setMaxWidth(CONTENT_WIDTH), - ) - } - } primaryButton = addRenderableWidget( Button.builder( Component.translatable("connect_share.friends.save_changes"), @@ -618,7 +824,7 @@ class ShareJoinScreen( friends.rename(friend.peerId, nameValue) friends.updateInternetDirectGuestOptIn( friend.peerId, - guestInternetDirect.selected(), + internetDirectSelected, ) friends.updatePermissions( friend.peerId, @@ -634,7 +840,12 @@ class ShareJoinScreen( selectedPeerId = null rebuildWidgets() } - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -642,24 +853,35 @@ class ShareJoinScreen( ) { removeConfirmation = true rebuildWidgets() - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) + .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, - ), - 30, + ).withStyle(ChatFormatting.BOLD), + layout.headerY + 12, ), ) addRenderableWidget( @@ -667,7 +889,8 @@ class ShareJoinScreen( Component.translatable( "connect_share.friends.remove_confirm.message", ), - 58, + layout.bodyTop, + layout.contentWidth, ), ) addRenderableWidget( @@ -690,7 +913,12 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 155, height - 28, 98, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) addRenderableWidget( Button.builder( @@ -710,13 +938,23 @@ class ShareJoinScreen( nameValue = "" rebuildWidgets() } - }.bounds(width / 2 - 51, height - 28, 98, 20).build(), + }.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(width / 2 + 53, height - 28, 102, 20).build(), + }.bounds( + layout.contentX + 2 * ((layout.contentWidth - 12) / 3 + 6), + layout.footerTop + 24, + (layout.contentWidth - 12) / 3, + 20, + ).build(), ) } @@ -1057,6 +1295,7 @@ class ShareJoinScreen( friendLinkState != FriendLinkState.COPYING && when (mode) { Mode.ADD -> inputReady && nameValue.isNotBlank() + Mode.CONNECTION_OPTIONS -> true Mode.MANAGE -> nameValue.isNotBlank() Mode.FRIENDS -> true } @@ -1065,59 +1304,44 @@ class ShareJoinScreen( nameBox?.setEditable(!joining) } - private fun friendLabel(friend: FriendSummary): Component = when { - friend.activityKind == FriendActivityKind.HOSTING_WORLD -> + private fun friendsSummary(overview: FriendsOverview): Component = + overview.summary().let { presentation -> Component.translatable( - "connect_share.friends.hosting_world", - friend.displayName, - friend.activityDescription ?: "Minecraft world", - ) - - friend.activityKind == FriendActivityKind.PLAYING_SERVER -> - Component.translatable( - "connect_share.friends.playing_server", - friend.displayName, - friend.activityDescription ?: "Minecraft server", - ) - - friend.onlineViaLan -> - Component.translatable( - "connect_share.friends.ready_lan", - friend.displayName, - friend.worldName ?: "", - ) - - friend.onlineViaConnect -> - Component.translatable( - "connect_share.friends.ready_connect", - friend.displayName, - friend.worldName ?: "", - ) - - friend.activityKind == FriendActivityKind.ONLINE -> - Component.translatable( - "connect_share.friends.online", - friend.displayName, + 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 + }, ) + } - friend.connectAvailable -> + private fun friendStatus(friend: FriendSummary): Component = + friend.presentation().let { presentation -> Component.translatable( - "connect_share.friends.saved_connect", - friend.displayName, + presentation.statusKey, + *presentation.statusArguments.toTypedArray(), ) + } - else -> - Component.translatable( - "connect_share.friends.saved_offline", - friend.displayName, - ) + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "direct LAN" - Ingress.DIRECT_INTERNET -> "direct internet" - } + 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, @@ -1166,17 +1390,19 @@ class ShareJoinScreen( private fun centeredWrapped( message: Component, y: Int, + contentWidth: Int = CONTENT_WIDTH, ): MultiLineTextWidget = MultiLineTextWidget( - width / 2 - CONTENT_WIDTH / 2, + width / 2 - contentWidth / 2, y, message, font, - ).setMaxWidth(CONTENT_WIDTH).setCentered(true) + ).setMaxWidth(contentWidth).setCentered(true) private enum class Mode { FRIENDS, ADD, + CONNECTION_OPTIONS, MANAGE, } @@ -1197,7 +1423,7 @@ class ShareJoinScreen( private enum class FriendLinkState( val translationKey: String, ) { - IDLE("connect_share.friends.copy_my_link"), + 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"), 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 index c5f3f5b4c..4a04dd306 100644 --- 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 @@ -1,9 +1,12 @@ 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 @@ -15,28 +18,37 @@ class SharePrivacyScreen( 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( - width / 2 - 155, - 18, + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.privacy.description"), font, - ).setMaxWidth(310).setCentered(true), + ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val privacy = viewModel.state.value.presencePrivacy - privacyToggle("online", 66, privacy.showOnline) { value -> + privacyToggle("online", layout.bodyTop, privacy.showOnline) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showOnline = value), ) } - privacyToggle("playing", 92, privacy.showPlaying) { value -> + privacyToggle( + "playing", + layout.bodyTop + 24, + privacy.showPlaying, + ) { value -> viewModel.setPresencePrivacy( viewModel.state.value.presencePrivacy.copy(showPlaying = value), ) } privacyToggle( "current_server", - 118, + layout.bodyTop + 48, privacy.showCurrentServer, ) { value -> viewModel.setPresencePrivacy( @@ -45,11 +57,25 @@ class SharePrivacyScreen( ), ) } - privacyToggle("joinable", 144, privacy.showJoinable) { 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), + ) + addRenderableWidget( Button.builder( Component.translatable( @@ -65,22 +91,37 @@ class SharePrivacyScreen( ) diagnosticsCopied = true rebuildWidgets() - }.bounds(width / 2 - 75, height - 76, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder( Component.translatable( "connect_share.privacy.blocked", - ConnectShareClient.friendsViewModel().state.value.blocked.size, + ConnectShareClient.friendsViewModel() + .state.value.blocked.size, ), ) { minecraft.gui.setScreen(BlockedFriendsScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), + }.bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_DONE) { onClose() } - .bounds(width / 2 - 75, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX, + layout.footerTop + 24, + layout.contentWidth, + 20, + ).build(), ) } @@ -94,14 +135,27 @@ class SharePrivacyScreen( selected: Boolean, changed: (Boolean) -> Unit, ) { + val layout = AdaptiveShareLayout.form(width, height, 4) addRenderableWidget( Checkbox.builder( Component.translatable("connect_share.privacy.$key"), font, - ).pos(width / 2 - 155, y) + ).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 index 5c92adf39..349a5fa14 100644 --- 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 @@ -2,9 +2,14 @@ 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 @@ -16,32 +21,128 @@ class ShareSetupScreen( ) : 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() { - val current = viewModel.state.value - minecraft.singleplayerServer?.let { server -> - viewModel.setGameMode(server.defaultGameType.toShareGameMode()) - viewModel.setAllowCheats(server.worldData.isAllowCommands) + 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, 18)) addRenderableWidget( - centered( + centered(title.copy().withStyle(ChatFormatting.BOLD), layout.headerY), + ) + addRenderableWidget( + MultiLineTextWidget( + layout.contentX, + layout.subtitleY, Component.translatable("connect_share.setup.description"), - 36, - ).setMaxWidth(CONTENT_WIDTH), + 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()}") + Component.translatable( + "connect_share.game_mode.${mode.name.lowercase()}", + ) }, current.options.gameMode, ).withValues(ShareGameMode.entries) .create( - width / 2 - 155, - 68, - 150, + layout.contentX, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.gameMode"), ) { _, mode -> viewModel.setGameMode(mode) }, @@ -49,9 +150,9 @@ class ShareSetupScreen( addRenderableWidget( CycleButton.onOffBuilder(current.options.allowCheats) .create( - width / 2 + 5, - 68, - 150, + layout.contentX + layout.halfButtonWidth + 6, + layout.bodyTop + 24, + layout.halfButtonWidth, 20, Component.translatable("selectWorld.allowCommands"), ) { _, allowed -> viewModel.setAllowCheats(allowed) }, @@ -62,9 +163,9 @@ class ShareSetupScreen( current.options.maxGuests, ).withValues((1..16).toList()) .create( - width / 2 - 75, - 96, - 150, + layout.contentX, + layout.bodyTop + 48, + layout.contentWidth, 20, Component.translatable("connect_share.setup.max_guests"), ) { _, guests -> viewModel.setMaxGuests(guests) }, @@ -73,7 +174,7 @@ class ShareSetupScreen( Checkbox.builder( Component.translatable("connect_share.setup.internet"), font, - ).pos(width / 2 - 155, 126) + ).pos(layout.contentX, layout.bodyTop + 74) .selected(current.options.allowInternetDirect) .onValueChange { _, allowed -> viewModel.setAllowInternetDirect(allowed) @@ -87,35 +188,6 @@ class ShareSetupScreen( ) .build(), ) - addRenderableWidget( - centered( - Component.translatable( - "connect_share.setup.persistence", - ), - 154, - ).setMaxWidth(CONTENT_WIDTH), - ) - startButton = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.setup.start"), - ) { - viewModel.start() - minecraft.gui.setScreen(ShareStatusScreen(parent)) - }.bounds(width / 2 - 155, height - 28, 150, 20).build(), - ) - addRenderableWidget( - Button.builder( - Component.translatable("connect_share.privacy.title"), - ) { - minecraft.gui.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 75, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(CommonComponents.GUI_CANCEL) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), - ) - refresh() } override fun tick() { @@ -133,11 +205,14 @@ class ShareSetupScreen( 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 companion object { - const val CONTENT_WIDTH = 310 + 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/ShareStatusScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareStatusScreen.kt index 032ce83b8..7a8753392 100644 --- 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 @@ -2,10 +2,14 @@ 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.Ingress 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 @@ -16,100 +20,106 @@ class ShareStatusScreen( ) : 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() - addRenderableWidget(centered(title, 14)) - + val layout = AdaptiveShareLayout.form(width, height, 3) val sharing = state.shareState as? ShareState.Sharing - val publicAddress = sharing?.address - val summary = when { - publicAddress != null -> - Component.translatable( - "connect_share.status.address", - publicAddress, - ) - - sharing != null -> - Component.translatable("connect_share.status.direct_only") - else -> Component.translatable(statusKey(state.shareState)) - } addRenderableWidget( - centered(summary, 32), - ) - val copyInvitation = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_invitation"), - ) { - viewModel.currentInvitation()?.let( - minecraft.keyboardHandler::setClipboard, - ) - }.bounds(width / 2 - 155, 50, 150, 20).build(), + centered( + title.copy().withStyle( + if (sharing != null) { + ChatFormatting.GREEN + } else { + ChatFormatting.YELLOW + }, + ), + layout.headerY, + ), ) - copyInvitation.active = viewModel.currentInvitation() != null - val copyAddress = addRenderableWidget( - Button.builder( - Component.translatable("connect_share.status.copy_address"), - ) { - sharing?.address?.let(minecraft.keyboardHandler::setClipboard) - }.bounds(width / 2 + 5, 50, 150, 20).build(), + addRenderableWidget( + centeredWrapped( + Component.translatable(statusKey(state.shareState)) + .withStyle(ChatFormatting.GRAY), + layout.subtitleY, + layout.contentWidth, + ), ) - copyAddress.active = sharing?.address != null + var requestHeadingY = layout.bodyTop + 54 if (sharing != null) { + val invitation = viewModel.currentInvitation() addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.link_help", + if (linkCopied) { + "connect_share.status.friend_link_copied" + } else { + "connect_share.status.copy_friend_link" + }, ), - 78, - ).setMaxWidth(CONTENT_WIDTH), + ) { + invitation?.let { + minecraft.keyboardHandler.setClipboard(it) + linkCopied = true + rebuildWidgets() + } + }.bounds( + layout.contentX, + layout.bodyTop, + layout.contentWidth, + 20, + ).build().apply { active = invitation != null }, ) addRenderableWidget( - centered( + Button.builder( Component.translatable( - "connect_share.status.connection_help", + if (showConnectionDetails) { + "connect_share.status.connection_details.hide" + } else { + "connect_share.status.connection_details.show" + }, ), - 94, - ).setMaxWidth(CONTENT_WIDTH), + ) { + showConnectionDetails = !showConnectionDetails + rebuildWidgets() + }.bounds( + layout.contentX, + layout.bodyTop + 24, + layout.contentWidth, + 20, + ).build(), ) + if (showConnectionDetails) { + addConnectionDetails(layout, sharing) + requestHeadingY = layout.bodyTop + 102 + } } - addRenderableWidget( - Button.builder(Component.translatable("connect_share.privacy.title")) { - minecraft.gui.setScreen(SharePrivacyScreen(this)) - }.bounds(width / 2 - 155, height - 52, 150, 20).build(), - ) - addRenderableWidget( - Button.builder(Component.translatable("connect_share.identity.manage")) { - minecraft.gui.setScreen(EndpointIdentityScreen(this)) - }.bounds(width / 2 + 5, height - 52, 150, 20).build(), - ) - - val pending = state.pendingAdmissions addRenderableWidget( centered( - Component.translatable( - "connect_share.status.requests", - ), - 110, + Component.translatable("connect_share.status.requests") + .withStyle(ChatFormatting.BOLD), + requestHeadingY, ), ) - val visibleRows = ((height - 174) / 26).coerceIn(1, 2) + 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 = 124 + index * 26 + val y = rowsTop + index * 24 + val buttonWidth = 54 + val labelWidth = layout.contentWidth - buttonWidth * 2 - 10 val identity = request.identity - val badge = when (identity) { - is AdmissionIdentity.Authenticated -> listOfNotNull( - identity.source.name.lowercase(), - identity.ingress.takeUnless { it == Ingress.CONNECT } - ?.displayName(), - ).joinToString(" · ") - + val ingress = when (identity) { + is AdmissionIdentity.Authenticated -> + identity.ingress is AdmissionIdentity.UnverifiedOffline -> - "offline · ${identity.ingress.displayName()}" + identity.ingress } val label = Component.translatable( if (request.purpose == AdmissionPurpose.FRIEND) { @@ -118,27 +128,42 @@ class ShareStatusScreen( "connect_share.status.request" }, identity.name, - badge, + friendlyIngress(ingress), ) addRenderableWidget( StringWidget( - width / 2 - 155, + layout.contentX, y, - 202, + labelWidth, 20, label, font, - ).setMaxWidth(202), + ).setMaxWidth(labelWidth), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.allow")) { + Button.builder( + Component.translatable("connect_share.status.allow"), + ) { viewModel.allow(request.requestId) - }.bounds(width / 2 + 51, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - + buttonWidth * 2 - 4, + y, + buttonWidth, + 20, + ).build(), ) addRenderableWidget( - Button.builder(Component.translatable("connect_share.status.deny")) { + Button.builder( + Component.translatable("connect_share.status.deny"), + ) { viewModel.deny(request.requestId) - }.bounds(width / 2 + 105, y, 50, 20).build(), + }.bounds( + layout.contentX + layout.contentWidth - buttonWidth, + y, + buttonWidth, + 20, + ).build(), ) } if (pending.size > visibleRows) { @@ -147,29 +172,104 @@ class ShareStatusScreen( Component.translatable( "connect_share.status.more", pending.size - visibleRows, - ), - 124 + visibleRows * 26, + ).withStyle(ChatFormatting.GRAY), + rowsTop + visibleRows * 24, ), ) } else if (pending.isEmpty()) { addRenderableWidget( centered( - Component.translatable("connect_share.status.waiting"), - 128, + 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.status.stop")) { + 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(width / 2 - 155, height - 28, 150, 20).build(), + }.bounds( + layout.contentX, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) addRenderableWidget( Button.builder(CommonComponents.GUI_BACK) { onClose() } - .bounds(width / 2 + 5, height - 28, 150, 20) - .build(), + .bounds( + layout.contentX + layout.halfButtonWidth + 6, + layout.footerTop + 24, + layout.halfButtonWidth, + 20, + ).build(), ) } @@ -185,26 +285,58 @@ class ShareStatusScreen( 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, 9, message, font) - } + 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 Ingress.displayName(): String = when (this) { - Ingress.CONNECT -> "connect" - Ingress.DIRECT_LAN -> "lan" - Ingress.DIRECT_INTERNET -> "internet" - } + 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.active" + is ShareState.Sharing -> "connect_share.status.ready" ShareState.Stopping -> "connect_share.status.stopping" is ShareState.Failed -> "connect_share.status.failed" } - private companion object { - const val CONTENT_WIDTH = 310 + 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/resources/assets/connect-share/lang/de_de.json b/share/fabric-26.2/src/main/resources/assets/connect-share/lang/de_de.json index 6aded9c15..1c25bf660 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Mit Freunden teilen", - "connect_share.menu.active": "Mit Freunden geteilt", + "connect_share.menu.share": "Welt teilen", + "connect_share.menu.active": "Welt geteilt", "connect_share.menu.join": "Freunde", - "connect_share.setup.title": "Diese Welt teilen", - "connect_share.setup.description": "Verknüpfte Freunde können dieser und zukünftigen Welten ohne neuen Link beitreten.", + "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": "Mit Freunden teilen", + "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": "Welt ist für Freunde bereit", + "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": "Freundschafts- und Beitrittsanfragen", + "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", @@ -146,5 +146,70 @@ "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.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" } 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 index ab376b8a5..62a275f9a 100644 --- 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 @@ -1,26 +1,26 @@ { - "connect_share.menu.share": "Share with friends", - "connect_share.menu.active": "Sharing with friends", + "connect_share.menu.share": "Share world", + "connect_share.menu.active": "World shared", "connect_share.menu.join": "Friends", - "connect_share.setup.title": "Share this world", - "connect_share.setup.description": "Linked friends can join this and future worlds without another link.", + "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": "Share with friends", + "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": "World ready for friends", + "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": "Friend and join requests", + "connect_share.status.requests": "Requests waiting for you", "connect_share.status.idle": "Not sharing", "connect_share.status.starting": "Starting…", "connect_share.status.active": "Active", @@ -46,17 +46,19 @@ "connect_share.join.join": "Join", "connect_share.friends.title": "Friends", "connect_share.friends.page": "Page %s of %s", - "connect_share.friends.description": "Friends appear here when they're ready to play.", + "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": "No friends or sent requests yet. Paste a friend's link to send one.", - "connect_share.friends.outgoing_request": "Request to %s", - "connect_share.friends.incoming_request": "Request from %s · %s", + "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": "%s · request in progress", + "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…", @@ -65,6 +67,17 @@ "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", @@ -79,14 +92,16 @@ "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 friend", - "connect_share.friends.add_description": "Paste your friend's link and send a request. They can accept while their game is open.", + "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": "Name of the person who sent the link", + "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 once", + "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", @@ -146,5 +161,55 @@ "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": "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" } 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 index 44ec7811a..a31c88553 100644 --- 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 @@ -27,12 +27,12 @@ class Fabric262ArtifactTest { ).bufferedReader().use { it.readText() } assertTrue( - "\"connect_share.setup.title\": \"Share this world\"" in + "\"connect_share.setup.title\": \"Play this world with friends\"" in language, ) assertTrue( - "\"connect_share.status.copy_invitation\": " + - "\"Copy friend link\"" in language, + "\"connect_share.status.copy_friend_link\": " + + "\"Copy invite for a new friend\"" in language, ) assertTrue( "\"connect_share.friends.copy_my_link\": " + @@ -44,11 +44,11 @@ class Fabric262ArtifactTest { ) assertTrue( "\"connect_share.friends.outgoing_request\": " + - "\"Request to %s\"" in language, + "\"Waiting for %s\"" in language, ) assertTrue( "\"connect_share.friends.incoming_request\": " + - "\"Request from %s · %s\"" in language, + "\"%s wants to be friends · %s\"" in language, ) assertTrue( "\"connect_share.friends.retry_request\": \"Retry\"" in 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 index 2db152629..901231dfd 100644 --- 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 @@ -4,6 +4,8 @@ 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 fun interface ConnectShareScreenFactory { fun open(parent: Any, active: Boolean) @@ -64,6 +66,26 @@ object ConnectShareClient { "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 -> 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..69a0b7465 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayout.kt @@ -0,0 +1,109 @@ +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 + } +} + +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, + @Suppress("UNUSED_PARAMETER") 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 = 58 + return FormScreenLayout( + contentX = contentX, + contentWidth = contentWidth, + headerY = 14, + subtitleY = 32, + bodyTop = bodyTop, + availableBodyHeight = footerTop - bodyTop, + footerTop = footerTop, + footerBottom = footerBottom, + ) + } + + 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/ShareScreenPresentation.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt new file mode 100644 index 000000000..2687f4aa2 --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt @@ -0,0 +1,201 @@ +package com.minekube.connect.share.fabric.ui + +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, +) + +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 || onlineViaLan -> + "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/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/PrismFriendJoinE2ETest.kt index 998aad2c3..54f9b5e11 100644 --- 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 @@ -38,11 +38,13 @@ class PrismFriendJoinE2ETest { val dataDirectory = Path.of(checkNotNull(dataValue)) val portFile = Path.of(checkNotNull(portValue)) 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 joinedLine = "] $playerName joined the game" val joinsBefore = Files.readString(hostLog) .lineSequence() .count { joinedLine in it } + val guestLoadsBefore = guestLog?.let(::loadedAdvancementsCount) val friend = FriendStore(dataDirectory).all().single() System.getenv("LIVE_HOST_DATA")?.let { hostDataValue -> val guestPeerId = DirectP2pNode( @@ -85,13 +87,17 @@ class PrismFriendJoinE2ETest { ) // Status and gameplay require different one-shot proxies. - assertTrue( - browser.probeLan( - friend, - DirectP2pAuthMode.OFFLINE, - MinecraftStatusProbe(), - ) != null, - ) + withTimeout(30_000) { + while ( + browser.probeLan( + friend, + DirectP2pAuthMode.OFFLINE, + MinecraftStatusProbe(), + ) == null + ) { + delay(250) + } + } val playerUuid = UUID.nameUUIDFromBytes( "OfflinePlayer:$playerName".toByteArray( StandardCharsets.UTF_8, @@ -133,9 +139,28 @@ class PrismFriendJoinE2ETest { delay(100) } } + if (guestLog != null && guestLoadsBefore != null) { + withTimeout(180_000) { + while ( + loadedAdvancementsCount(guestLog) <= + guestLoadsBefore + ) { + delay(100) + } + } + } } } finally { browser.close() } } + + private fun loadedAdvancementsCount(log: Path): Int = + if (Files.exists(log)) { + Files.readString(log).lineSequence().count { + "Loaded " in it && " advancements" in it + } + } else { + 0 + } } 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..3a76ee730 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/AdaptiveShareLayoutTest.kt @@ -0,0 +1,48 @@ +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) + } +} 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..418718cc0 --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt @@ -0,0 +1,192 @@ +package com.minekube.connect.share.fabric.ui + +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) + } + + 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, + ) +} From 9ff07d5894d0ad3bbd3ed1b3be282c0ecb13efec Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 13:01:36 +0200 Subject: [PATCH 152/188] no-mistakes(review): Preserve Share privacy, recovery, localization, and accessibility --- .../connect/share/friend/FriendStore.kt | 7 +- .../connect/share/friend/FriendStoreTest.kt | 41 +++++++-- .../fabric/v1_20_1/ConnectShare12111Client.kt | 18 +++- .../fabric/v1_20_1/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v1_20_1/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../fabric/v1_21_1/ConnectShare12111Client.kt | 18 +++- .../fabric/v1_21_1/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v1_21_1/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../v1_21_11/ConnectShare12111Client.kt | 18 +++- .../fabric/v1_21_11/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v1_21_11/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../fabric/v26_2/ConnectShare262Client.kt | 18 +++- .../fabric/v26_2/EndpointIdentityScreen.kt | 6 +- .../share/fabric/v26_2/ShareJoinScreen.kt | 81 ++++++++++------- .../assets/connect-share/lang/de_de.json | 34 ++++++- .../assets/connect-share/lang/en_us.json | 34 ++++++- .../share/fabric/ui/FriendsViewModel.kt | 33 +++---- .../fabric/ui/ShareScreenPresentation.kt | 2 +- .../connect/share/fabric/ui/ShareUiMessage.kt | 90 +++++++++++++++++++ .../connect/share/fabric/ui/ShareViewModel.kt | 29 +++--- .../share/fabric/ui/FriendsViewModelTest.kt | 35 +++++++- .../share/fabric/ui/ShareViewModelTest.kt | 25 +++++- 28 files changed, 766 insertions(+), 188 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt 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 index 9873a9ac8..1f40b5ca9 100644 --- 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 @@ -360,7 +360,12 @@ class FriendStore( friend = removed, removedAt = now, ) - write(StoreData(remaining, removals)) + write( + data().copy( + friends = remaining, + removals = removals, + ), + ) return true } 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 index 76c3b0f1f..01b8ed304 100644 --- 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 @@ -347,6 +347,31 @@ class FriendStoreTest { ) } + @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) @@ -460,30 +485,34 @@ class FriendStoreTest { 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 = SHARE_ID, + shareId = shareId, expiresAtEpochMillis = expiresAt.toEpochMilli(), connectAddress = CONNECT_ADDRESS, - peerId = PEER_ID, + peerId = peerId, internetDirectEnabled = internetDirectEnabled, directCandidates = directCandidates, - capability = CAPABILITY, + capability = capability, ) val unsigned = ShareInviteCodec.unsignedBytes( payload, - KEY_PAIR.public.encoded, + keyPair.public.encoded, ) val signature = Signature.getInstance("Ed25519").run { - initSign(KEY_PAIR.private) + initSign(keyPair.private) update(unsigned) sign() } return ShareInviteCodec.encode( SignedShareInvite( payload = payload, - publicKey = KEY_PAIR.public.encoded, + publicKey = keyPair.public.encoded, signature = signature, ), ) 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 index 1e7014276..3a5a0a85f 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -334,7 +336,7 @@ class ConnectShare1201Runtime( minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -413,16 +415,28 @@ class ConnectShare1201Runtime( 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 954821569..5232e5aa3 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/ShareJoinScreen.kt index 3538520a3..54c8110ae 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft!!.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -734,7 +754,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1042,7 +1062,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1099,9 +1119,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1114,10 +1132,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1136,9 +1154,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1157,10 +1175,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1177,18 +1195,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1215,7 +1231,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1246,7 +1262,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1366,9 +1382,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index d8fe5f171..ddd260c03 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -334,7 +336,7 @@ class ConnectShare1211Runtime( minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -418,16 +420,28 @@ class ConnectShare1211Runtime( 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 3dd629db1..ff5200f30 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/ShareJoinScreen.kt index 999a4e690..83fca4931 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft!!.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -732,7 +752,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1036,7 +1056,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1093,9 +1113,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1108,10 +1126,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1130,9 +1148,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1151,10 +1169,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1171,18 +1189,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1209,7 +1225,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1240,7 +1256,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1360,9 +1376,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index cd589871c..d2cd5b0ed 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -350,7 +352,7 @@ class ConnectShare12111Client : ClientModInitializer { minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -434,16 +436,28 @@ class ConnectShare12111Client : ClientModInitializer { 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index cc26e338f..eaaa99b40 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/ShareJoinScreen.kt index ccde70a2f..c1f104488 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -732,7 +752,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1036,7 +1056,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1093,9 +1113,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1108,10 +1126,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1130,9 +1148,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1151,10 +1169,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1171,18 +1189,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1209,7 +1225,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1240,7 +1256,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1360,9 +1376,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 78dccc222..6b7a47a28 100644 --- 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 @@ -19,6 +19,8 @@ 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.uiMessage import com.minekube.connect.share.ShareState import com.minekube.connect.share.friend.FriendStore import com.minekube.connect.share.friend.FriendActivity @@ -350,7 +352,7 @@ class ConnectShare262Client : ClientModInitializer { minecraft, "connect_share.notification.follow_failed", null, - failure.safeMessage, + failure.uiMessage().component(), ) } }, @@ -434,16 +436,28 @@ class ConnectShare262Client : ClientModInitializer { 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) } - ?: Component.literal(value), + ?: value, ) } + private fun ShareUiMessage.component() = + Component.translatable(translationKey, *arguments.toTypedArray()) + private fun SocialEvent.title(): Component = Component.translatable( when (this) { is SocialEvent.FriendAccepted -> 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 index 32c93b7a6..466e2e896 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -118,7 +119,7 @@ class EndpointIdentityScreen( MultiLineTextWidget( layout.contentX, layout.bodyTop + 104, - Component.literal(safeMessage) + safeMessage.component() .withStyle(ChatFormatting.YELLOW), font, ).setMaxWidth(layout.contentWidth).setCentered(true), @@ -245,6 +246,9 @@ class EndpointIdentityScreen( } } +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/ShareJoinScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/ShareJoinScreen.kt index 7db8edddf..6d7ca0d17 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -72,7 +74,7 @@ class ShareJoinScreen( private var internetDirect: Checkbox? = null private var primaryButton: Button? = null private var secondaryButton: Button? = null - private var safeMessage: String? = null + private var safeMessage: ShareUiMessage? = null private var fingerprint = 0 private var joining = false private var joiningPeerId: String? = null @@ -90,7 +92,7 @@ class ShareJoinScreen( scope = CoroutineScope( SupervisorJob() + minecraft.asCoroutineDispatcher(), ) - browser.start().onLeft { safeMessage = it.safeMessage } + browser.start().onLeft { safeMessage = it.uiMessage() } } friends.updatePresence(browser.discovered.value) friends.updateRemotePresence(remotePresence.state.value) @@ -213,9 +215,16 @@ class ShareJoinScreen( } } if (page.pageCount > 1) { - val pageTooltip = Tooltip.create( + val previousTooltip = Tooltip.create( Component.translatable( - "connect_share.friends.page", + "connect_share.page.previous_tooltip", + page.pageNumber, + page.pageCount, + ), + ) + val nextTooltip = Tooltip.create( + Component.translatable( + "connect_share.page.next_tooltip", page.pageNumber, page.pageCount, ), @@ -225,7 +234,10 @@ class ShareJoinScreen( relationshipOffset = page.previousOffset ?: 0 rebuildWidgets() }.bounds(layout.contentX, layout.headerY, 24, 20) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.previous") + } + .tooltip(previousTooltip) .build(), ) previous.active = page.hasPrevious @@ -239,7 +251,10 @@ class ShareJoinScreen( 24, 20, ) - .tooltip(pageTooltip) + .createNarration { + Component.translatable("connect_share.page.next") + } + .tooltip(nextTooltip) .build(), ) next.active = page.hasNext @@ -249,7 +264,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message) + message.component() .withStyle(ChatFormatting.YELLOW), layout.messageY, layout.contentWidth, @@ -492,7 +507,12 @@ class ShareJoinScreen( friend.displayName, ), ), - ).build(), + ).createNarration { + Component.translatable( + "connect_share.friends.manage_named", + friend.displayName, + ) + }.build(), ) } @@ -592,7 +612,7 @@ class ShareJoinScreen( if (message != null) { addRenderableWidget( centeredWrapped( - Component.literal(message).withStyle(ChatFormatting.YELLOW), + message.component().withStyle(ChatFormatting.YELLOW), layout.footerTop - 16, layout.contentWidth, ), @@ -732,7 +752,7 @@ class ShareJoinScreen( addRenderableWidget( centeredWrapped( safeMessage()?.let { - Component.literal(it).withStyle(ChatFormatting.YELLOW) + it.component().withStyle(ChatFormatting.YELLOW) } ?: friendStatus(friend).copy().withStyle( friend.presentation().tone.color(), ), @@ -1036,7 +1056,7 @@ class ShareJoinScreen( ), ) } else { - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } }, @@ -1093,9 +1113,7 @@ class ShareJoinScreen( if (senderCard == null) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } @@ -1108,10 +1126,10 @@ class ShareJoinScreen( if (target == null) { requestFailed( peerId, - targetResult.leftOrNull()?.safeMessage - ?: Component.translatable( + targetResult.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1130,9 +1148,9 @@ class ShareJoinScreen( target.close() requestFailed( peerId, - Component.translatable( + ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch }, @@ -1151,10 +1169,10 @@ class ShareJoinScreen( if (hostCard == null) { requestFailed( peerId, - result.leftOrNull()?.safeMessage - ?: Component.translatable( + result.leftOrNull()?.uiMessage() + ?: ShareUiMessage( "connect_share.friends.request_failed", - ).string, + ), ) return@launch } @@ -1171,18 +1189,16 @@ class ShareJoinScreen( if (accepted.isLeft()) { requestFailed( peerId, - Component.translatable( - "connect_share.friends.request_failed", - ).string, + ShareUiMessage("connect_share.friends.request_failed"), ) return@launch } requestStates.remove(peerId) friends.reload() - safeMessage = Component.translatable( + safeMessage = ShareUiMessage( "connect_share.friends.request_accepted", - displayName, - ).string + listOf(displayName), + ) rebuildWidgets() } requestJobs[peerId] = job @@ -1209,7 +1225,7 @@ class ShareJoinScreen( private fun requestFailed( peerId: String, - message: String, + message: ShareUiMessage, ) { requestStates[peerId] = RequestDeliveryState.FAILED safeMessage = message @@ -1240,7 +1256,7 @@ class ShareJoinScreen( joining = false joiningPeerId = null reciprocalPairing = false - safeMessage = failure.safeMessage + safeMessage = failure.uiMessage() rebuildWidgets() } @@ -1360,9 +1376,12 @@ class ShareJoinScreen( it.peerId == selectedPeerId } - private fun safeMessage(): String? = + 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 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 index 1c25bf660..90b0b6e78 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 62a275f9a..8e51ee7e2 100644 --- 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 @@ -211,5 +211,37 @@ "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.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.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" } 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 index 268e88714..2a32d2391 100644 --- 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 @@ -69,7 +69,7 @@ data class FriendsUiState( val outgoingRequests: List = emptyList(), val incomingRequests: List = emptyList(), val blocked: List = emptyList(), - val safeMessage: String? = null, + val safeMessage: ShareUiMessage? = null, ) class FriendsViewModel( @@ -101,7 +101,7 @@ class FriendsViewModel( internetDirectGuestOptIn = internetDirectGuestOptIn, ).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } null }, ifRight = { request -> @@ -121,7 +121,7 @@ class FriendsViewModel( fun rename(peerId: String, displayName: String) { store.rename(peerId, displayName).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { refresh() @@ -135,7 +135,7 @@ class FriendsViewModel( ) { store.updatePermissions(peerId, permissions).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { refresh() @@ -149,7 +149,7 @@ class FriendsViewModel( ) { store.setInternetDirectGuestOptIn(peerId, enabled).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { refresh() }, ) @@ -410,21 +410,22 @@ class FriendsViewModel( activity.kind == FriendActivityKind.HOSTING_WORLD && remote != null ), - canJoinNow = remote != null && - 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 { - const val FRIENDS_LOAD_FAILURE = - "Saved Connect Share friends could not be loaded" - const val FRIEND_REMOVE_FAILURE = - "This Connect Share friend could not be removed" - const val FRIEND_BLOCK_FAILURE = - "This Connect Share identity could not be blocked" - const val FRIEND_UNBLOCK_FAILURE = - "This Connect Share identity could not be unblocked" + 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/ShareScreenPresentation.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentation.kt index 2687f4aa2..61e947b26 100644 --- 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 @@ -85,7 +85,7 @@ fun FriendSummary.presentation(): FriendRowPresentation { "connect_share.friends.status.server" to listOf(activityDescription ?: "Minecraft server") - canJoinNow || onlineViaLan -> + canJoinNow -> "connect_share.friends.status.ready" to emptyList() activityKind == FriendActivityKind.ONLINE || onlineViaConnect -> 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..0af87934f --- /dev/null +++ b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessage.kt @@ -0,0 +1,90 @@ +package com.minekube.connect.share.fabric.ui + +import com.minekube.connect.share.ShareLifecycleError +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(), +) + +fun FriendStoreError.uiMessage(): ShareUiMessage = when (this) { + is FriendStoreError.InvalidInvitation -> + ShareUiMessage("connect_share.error.invalid_invitation") + 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 -> + ShareUiMessage("connect_share.error.invalid_invitation") + 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 index 5fc38a7ca..82087d606 100644 --- 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 @@ -59,7 +59,7 @@ data class ShareUiState( val identity: EndpointIdentitySummary? = null, val importDraft: IdentityImportDraft = IdentityImportDraft(), val operationInProgress: Boolean = false, - val safeMessage: String? = null, + val safeMessage: ShareUiMessage? = null, ) { val startEnabled: Boolean get() = worldAvailable && @@ -341,7 +341,7 @@ class ShareViewModel( ) { result.fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { identity -> onIdentityChanged() @@ -365,7 +365,7 @@ class ShareViewModel( private suspend fun startCurrentWorld() { startShare(state.value.options).fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { update { @@ -381,7 +381,7 @@ class ShareViewModel( private suspend fun stopCurrentWorld() { stopShare().fold( ifLeft = { failure -> - update { copy(safeMessage = failure.safeMessage) } + update { copy(safeMessage = failure.uiMessage()) } }, ifRight = { update { @@ -413,13 +413,12 @@ class ShareViewModel( state.value.worldAvailable && state.value.shareState is ShareState.Idle private fun canStopCurrentWorld(): Boolean = when (state.value.shareState) { - ShareState.Idle, - is ShareState.Failed, - -> false + ShareState.Idle -> false ShareState.Starting, is ShareState.Sharing, ShareState.Stopping, + is ShareState.Failed, -> true } @@ -454,14 +453,14 @@ class ShareViewModel( ) private companion object { - const val MANAGED_MESSAGE = - "Connect credentials are managed by the environment" - const val GENERIC_FAILURE_MESSAGE = - "Could not update Connect Share" - const val IDENTITY_ACTIVE_MESSAGE = - "Stop sharing before changing Connect credentials" - const val PREFERENCES_FAILURE_MESSAGE = - "Connect Share privacy settings could not be saved" + 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") } } 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 index 462052f60..c6829bc32 100644 --- 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 @@ -180,7 +180,10 @@ class FriendsViewModelTest { assertEquals(null, accepted) assertTrue(viewModel.state.value.friends.isEmpty()) - assertTrue(viewModel.state.value.safeMessage?.isNotBlank() == true) + assertTrue( + viewModel.state.value.safeMessage?.translationKey?.isNotBlank() == + true, + ) } @Test @@ -326,6 +329,36 @@ class FriendsViewModelTest { 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 `playing on a server exposes request to join instead of direct join`() { val store = FriendStore(tempDir) 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 index 0750e4db5..5b6946d29 100644 --- 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 @@ -50,6 +50,23 @@ class ShareViewModelTest { 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() @@ -105,8 +122,8 @@ class ShareViewModelTest { assertFalse(viewModel.state.value.importDraft.tokenEditable) assertEquals(0, identityActions.importCalls) assertEquals( - "Connect credentials are managed by the environment", - viewModel.state.value.safeMessage, + "connect_share.error.identity_managed", + viewModel.state.value.safeMessage?.translationKey, ) } @@ -227,8 +244,8 @@ class ShareViewModelTest { assertEquals(0, identityActions.importCalls) assertEquals( - "Stop sharing before changing Connect credentials", - viewModel.state.value.safeMessage, + "connect_share.error.identity_active", + viewModel.state.value.safeMessage?.translationKey, ) } From 584324fd18843134599291b50188d8aa3f313a2f Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 13:46:29 +0200 Subject: [PATCH 153/188] no-mistakes(document): Consolidate Share docs and correct E2E guidance --- .../skills/connect-share-prism-e2e/SKILL.md | 8 +++---- README.md | 24 ++----------------- docs/connect-share-testing.md | 2 +- 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 8e5119414..a1fe295c9 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -58,10 +58,10 @@ prismlauncher --launch --offline \ `--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 `Published LAN -server`. 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`. +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`. ## Run the opt-in live harness diff --git a/README.md b/README.md index c13564391..8179ff47a 100644 --- a/README.md +++ b/README.md @@ -20,29 +20,9 @@ It shares a singleplayer world through Minekube Connect or directly between two modded clients without exposing Minecraft's listener to the LAN or internet. -The current implementation provides: - -- a native **Share with friends** flow in the pause menu; -- a native **Friends** flow on the title screen, including **Join Connect Share**; -- one persistent endpoint identity reused across worlds and restarts; -- one authenticated libp2p friend identity, with presence and world details - visible only to confirmed friends; -- import of an existing dashboard endpoint and token, including `token.json`; -- `CONNECT_ENDPOINT` and `CONNECT_TOKEN` environment overrides; -- a stable `*.play.minekube.net` address for unmodified Java clients; -- signed friend links and temporary world invitations for modded clients; -- automatic same-LAN discovery and direct libp2p transport; -- direct libp2p friend delivery from explicitly shared friend links when a - direct route exists, plus opt-in internet-direct gameplay attempts; -- exactly-once fallback to Connect, which is the only relay; -- host approval before each new guest reaches the world; -- explicit support for authenticated and unverified offline-mode guests; and -- compatibility checks before a friend requests access; -- follow-next-session intents that never interrupt active gameplay; and -- isolated, version-and-loader-labelled artifacts for every supported target. - See [the player, privacy, installation, and distribution guide](docs/connect-share.md) -for the supported versions, required dependencies, and release details. +for the supported versions, required dependencies, player flow, and release +details. The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index adb33b1f1..f93c51c43 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -118,7 +118,7 @@ Connect. 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 one bounded direct attempt is followed by exactly one + 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 From aa3e87a227c5c87a1aee567087c22c3bca1dcb35 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 13:51:04 +0200 Subject: [PATCH 154/188] no-mistakes(document): Correct Share docs and E2E guidance --- .agents/skills/connect-share-prism-e2e/SKILL.md | 4 ++-- docs/connect-share.md | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index a1fe295c9..55750e771 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -40,7 +40,7 @@ or replace older Connect Share JARs so each instance loads exactly one. Compare SHA-256 digests for the build output and both installed copies. Confirm each fresh `latest.log` contains both Fabric Loader startup and a -`connect-share` mod entry. Fabric Language Kotlin is packaged as a declared mod +`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 @@ -76,7 +76,7 @@ LIVE_HOST_LOG= \ LIVE_GUEST_LOG= \ LIVE_PLAYER_NAME= \ ./gradlew :share:fabric-common:test \ - --tests '*PrismFriendJoinE2ETest*' --no-parallel + --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel ``` The test must remain running while the external guest uses the port written to diff --git a/docs/connect-share.md b/docs/connect-share.md index b278b0cc5..780412803 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -49,8 +49,10 @@ or blocking cannot be bypassed with an old attempt. - Only confirmed peer identities receive presence. Display names are labels, never identity or authorization. -- Online, playing, current server/world name, and joinable state can each be - hidden independently under **Privacy**. +- Online, playing, and joinable state can each be hidden independently under + **Privacy**. When a friend is on another server, **Show current server** can + also hide that server's name; the current singleplayer world name remains + visible while hosting. - Each friend can be set to **Ask Every Time**, **Auto-Accept**, or **Never Allow**. The default is Ask Every Time. - Removing a friend revokes future presence and admissions and is synchronized From 5a523f8f9c14f7d8bb12f89561a909fb6dc1ddb1 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 14:29:51 +0200 Subject: [PATCH 155/188] fix(share): enforce private presence boundaries --- .../skills/connect-share-prism-e2e/SKILL.md | 7 +- docs/connect-share.md | 7 +- share/AGENTS.md | 8 +- .../connect/share/ShareConnectionGateway.kt | 141 ++++++++++++++++++ .../friend/FriendControlChannelHandler.kt | 33 ++-- .../share/ShareConnectionGatewayTest.kt | 82 ++++++++++ .../v1_20_1/Minecraft12111LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../v1_21_1/Minecraft12111LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../v1_21_11/Minecraft12111LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../fabric/v26_2/Minecraft262LoginBridge.kt | 14 +- .../assets/connect-share/lang/de_de.json | 11 ++ .../assets/connect-share/lang/en_us.json | 11 ++ .../share/fabric/FriendRequestServer.kt | 16 +- .../share/fabric/ui/FriendsViewModel.kt | 9 +- .../connect/share/fabric/ui/ShareUiMessage.kt | 38 ++++- .../share/fabric/FriendRequestServerTest.kt | 79 ++++++++++ .../share/fabric/ui/FriendsViewModelTest.kt | 61 +++++++- .../share/fabric/ui/ShareUiMessageTest.kt | 71 +++++++++ 24 files changed, 633 insertions(+), 63 deletions(-) create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 55750e771..59e80b409 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -138,8 +138,11 @@ normal pending request, host approval, and one-shot admission path. `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. A Connect endpoint fallback status or public - DNS response does not prove the integrated world is reachable. +- **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. diff --git a/docs/connect-share.md b/docs/connect-share.md index 780412803..58866ae1e 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -50,9 +50,10 @@ or blocking cannot be bypassed with an old attempt. - 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**. When a friend is on another server, **Show current server** can - also hide that server's name; the current singleplayer world name remains - visible while hosting. + **Privacy**. **Show current server or world** hides both multiplayer server + names and singleplayer world names. Raw Minecraft status is not treated as + presence: it is accepted only for a confirmed friend when online, playing, + and current-world visibility are all enabled. - Each friend can be set to **Ask Every Time**, **Auto-Accept**, or **Never Allow**. The default is Ask Every Time. - Removing a friend revokes future presence and admissions and is synchronized diff --git a/share/AGENTS.md b/share/AGENTS.md index 555c5ae53..24f7f4b9b 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -74,7 +74,7 @@ redesigned for Kotlin. `--offline ` is authoritative; editing `InstanceAccountId` while Prism runs is not, because Prism rewrites it. - Prove the flow in layers: mDNS discovery, authenticated friend activity, - Minecraft status, then a real login whose host log contains + Minecraft status when host privacy permits it, then a real login whose host log contains ` joined the game`. Control-plane reachability or a status response does not prove that the world is joinable. `dns-sd -B _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are @@ -86,6 +86,12 @@ redesigned for Kotlin. open a separate target for gameplay and keep that target alive until the Minecraft connection finishes. Never reuse the friend-control target for a status probe or login. +- Authenticated friend activity is the authority for visible online, playing, + world-name, and joinable state. Never promote raw Minecraft status into UI + presence without a matching privacy-filtered activity response. The gateway + rejects status for unknown peers and whenever online, playing, or the current + server/world name is hidden; login remains independently admissible so a + privacy-safe join request can still succeed. - 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`; otherwise friends see a world that cannot yet 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 index 6e57abf04..74197e00d 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -6,6 +6,9 @@ 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 @@ -21,6 +24,7 @@ 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 @@ -123,6 +127,10 @@ class ShareConnectionGateway private constructor( FRIEND_CONTROL_HANDLER, FriendControlChannelHandler(friendServer), ) + channel.pipeline().addLast( + MINECRAFT_STATUS_PRIVACY_HANDLER, + MinecraftStatusPrivacyHandler(friendServer), + ) channel.pipeline().addLast( MINECRAFT_DISPATCH_HANDLER, MinecraftDispatchHandler(activeMinecraft), @@ -137,6 +145,133 @@ class ShareConnectionGateway private constructor( 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?>, @@ -186,9 +321,15 @@ class ShareConnectionGateway private constructor( "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/friend/FriendControlChannelHandler.kt b/share/common/src/main/kotlin/com/minekube/connect/share/friend/FriendControlChannelHandler.kt index 50af91947..bbbd7b4aa 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -47,6 +48,25 @@ fun interface FriendControlServer { 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( @@ -297,17 +317,6 @@ class FriendControlChannelHandler( } private fun ChannelHandlerContext.controlContext(): FriendControlContext { - val direct = channel() - .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(), - ) + return channel().friendControlContext() } } 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 index 80bae656c..4f6376fa6 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -2,8 +2,10 @@ 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 @@ -19,6 +21,7 @@ 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.TimeUnit import kotlin.test.Test import kotlin.test.assertContentEquals @@ -27,6 +30,77 @@ import kotlin.test.assertIs import kotlin.test.assertTrue class ShareConnectionGatewayTest { + @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() @@ -290,6 +364,14 @@ class ShareConnectionGatewayTest { } 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", 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 index cd65ba8ba..463750eb4 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -126,8 +126,8 @@ object Minecraft1201LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -169,10 +169,6 @@ object Minecraft1201LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index d9cb7576b..1ff90a1c3 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -127,8 +127,8 @@ object Minecraft1211LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -170,10 +170,6 @@ object Minecraft1211LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 50cb67665..a6235397d 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -127,8 +127,8 @@ object Minecraft12111LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -170,10 +170,6 @@ object Minecraft12111LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8820664b4..c7e5a3b32 100644 --- 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 @@ -6,10 +6,10 @@ 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.DirectOnlineAuthenticationRequired 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 @@ -127,8 +127,8 @@ object Minecraft262LoginBridge { ).onLeft { server.execute { deny.accept( - Component.literal( - DirectOnlineAuthenticationRequired.SAFE_MESSAGE, + Component.translatable( + ShareLoginMessages.AUTHENTICATION_REQUIRED, ), ) } @@ -170,10 +170,6 @@ object Minecraft262LoginBridge { DirectP2pRoute.INTERNET -> Ingress.DIRECT_INTERNET } - private fun denialReason(answer: AdmissionAnswer?): Component = when (answer) { - AdmissionAnswer.TIMEOUT -> Component.literal("Host approval timed out") - AdmissionAnswer.CAPACITY -> Component.literal("This share is full") - AdmissionAnswer.STOPPED -> Component.literal("Sharing stopped") - else -> Component.literal("Host denied this connection") - } + private fun denialReason(answer: AdmissionAnswer?): Component = + Component.translatable(ShareLoginMessages.denial(answer)) } 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 index 90b0b6e78..f39c01fbb 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index 8e51ee7e2..681973eee 100644 --- 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 @@ -223,6 +223,17 @@ "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", 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 index f7096b43a..64346887a 100644 --- 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 @@ -48,6 +48,17 @@ class FriendRequestServer( }, private val joinTarget: () -> String? = { null }, ) : FriendControlServer { + override fun allowsMinecraftStatus( + context: FriendControlContext, + ): Boolean { + val friend = authenticatedFriend(context) ?: return false + val privacy = presencePrivacy() + return friend.permissions.canSeeMyWorlds && + privacy.showOnline && + privacy.showPlaying && + privacy.showCurrentServer + } + override fun handle( context: FriendControlContext, request: FriendControlRequest, @@ -126,7 +137,10 @@ class FriendRequestServer( else -> current.copy( description = current.description.takeIf { - current.kind != FriendActivityKind.PLAYING_SERVER || + ( + current.kind != FriendActivityKind.PLAYING_SERVER && + current.kind != FriendActivityKind.HOSTING_WORLD + ) || privacy.showCurrentServer }, joinable = current.joinable && privacy.showJoinable && 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 index 2a32d2391..e9c39237c 100644 --- 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 @@ -390,9 +390,9 @@ class FriendsViewModel( } private fun SavedFriend.summary(): FriendSummary { - val remote = remotePresence[peerId] - ?.takeIf { it.online } val activity = activities[peerId] + val remote = remotePresence[peerId] + ?.takeIf { it.online && activity != null } return FriendSummary( peerId = peerId, displayName = displayName, @@ -401,14 +401,13 @@ class FriendsViewModel( internetDirectGuestOptIn = internetDirectGuestOptIn, onlineViaLan = remote?.route == ShareRoute.DIRECT_LAN, onlineViaConnect = remote?.route == ShareRoute.CONNECT, - worldName = remote?.description, + worldName = activity?.description, activityKind = activity?.kind, activityDescription = activity?.description, canRequestJoin = activity?.joinable == true && ( activity.kind == FriendActivityKind.PLAYING_SERVER || - activity.kind == FriendActivityKind.HOSTING_WORLD && - remote != null + activity.kind == FriendActivityKind.HOSTING_WORLD ), canJoinNow = activity?.joinable == true && remote != null && 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 index 0af87934f..e5cd4beba 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -13,9 +15,41 @@ data class ShareUiMessage( val arguments: List = emptyList(), ) +object ShareLoginMessages { + const val AUTHENTICATION_REQUIRED = + "connect_share.login.authentication_required" + + fun denial(answer: AdmissionAnswer?): String = when (answer) { + AdmissionAnswer.TIMEOUT -> + "connect_share.login.approval_timed_out" + AdmissionAnswer.CAPACITY -> + "connect_share.login.share_full" + AdmissionAnswer.STOPPED -> + "connect_share.login.sharing_stopped" + else -> "connect_share.login.host_denied" + } +} + +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 -> - ShareUiMessage("connect_share.error.invalid_invitation") + reason.uiMessage() FriendStoreError.InvalidDisplayName -> ShareUiMessage("connect_share.error.invalid_friend_name") FriendStoreError.IdentityConflict -> @@ -48,7 +82,7 @@ fun ShareLifecycleError.uiMessage(): ShareUiMessage = when (this) { fun GuestJoinFailure.uiMessage(): ShareUiMessage = when (this) { is GuestJoinFailure.InvalidInvitation -> - ShareUiMessage("connect_share.error.invalid_invitation") + error.uiMessage() GuestJoinFailure.PeerMismatch -> ShareUiMessage("connect_share.error.join_peer_mismatch") GuestJoinFailure.DiscoveryUnavailable -> 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 index 933f65017..44ac662d3 100644 --- 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 @@ -473,6 +473,85 @@ class FriendRequestServerTest { ) } + @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(), + ) + } + private fun kotlinx.coroutines.test.TestScope.admission() = AdmissionController( scope = backgroundScope, 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 index c6829bc32..357f8a7eb 100644 --- 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 @@ -104,7 +104,6 @@ class FriendsViewModelTest { ), ), ) - assertTrue(viewModel.state.value.friends.isEmpty()) assertEquals( PEER_ID, @@ -281,6 +280,14 @@ class FriendsViewModelTest { ), ), ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Robin's New World", + ), + ), + ) val online = viewModel.state.value.friends.single() assertTrue(online.onlineViaLan) @@ -323,6 +330,14 @@ class FriendsViewModelTest { ), ), ) + viewModel.updateActivities( + mapOf( + PEER_ID to FriendActivity( + FriendActivityKind.HOSTING_WORLD, + "Robin's Remote World", + ), + ), + ) val online = viewModel.state.value.friends.single() assertTrue(online.onlineViaConnect) @@ -359,6 +374,31 @@ class FriendsViewModelTest { ) } + @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) @@ -459,6 +499,25 @@ class FriendsViewModelTest { 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() 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..93a536d3d --- /dev/null +++ b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareUiMessageTest.kt @@ -0,0 +1,71 @@ +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( + "connect_share.login.authentication_required", + ShareLoginMessages.AUTHENTICATION_REQUIRED, + ) + assertEquals( + "connect_share.login.approval_timed_out", + ShareLoginMessages.denial(AdmissionAnswer.TIMEOUT), + ) + assertEquals( + "connect_share.login.share_full", + ShareLoginMessages.denial(AdmissionAnswer.CAPACITY), + ) + assertEquals( + "connect_share.login.sharing_stopped", + ShareLoginMessages.denial(AdmissionAnswer.STOPPED), + ) + assertEquals( + "connect_share.login.host_denied", + ShareLoginMessages.denial(AdmissionAnswer.DENY), + ) + } +} From 2d8fe0c018fab4c9988b209ae2c9df84d1470706 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 14:40:28 +0200 Subject: [PATCH 156/188] fix(share): keep visible status routes usable --- docs/connect-share.md | 5 +++-- share/AGENTS.md | 8 +++++--- .../share/fabric/FriendRequestServer.kt | 14 ++++++++----- .../share/fabric/FriendRequestServerTest.kt | 20 +++++++++++++++++++ 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/connect-share.md b/docs/connect-share.md index 58866ae1e..b2c7b4b00 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -52,8 +52,9 @@ or blocking cannot be bypassed with an old attempt. - Online, playing, and joinable state can each be hidden independently under **Privacy**. **Show current server or world** hides both multiplayer server names and singleplayer world names. Raw Minecraft status is not treated as - presence: it is accepted only for a confirmed friend when online, playing, - and current-world visibility are all enabled. + 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 Every Time**, **Auto-Accept**, or **Never Allow**. The default is Ask Every Time. - Removing a friend revokes future presence and admissions and is synchronized diff --git a/share/AGENTS.md b/share/AGENTS.md index 24f7f4b9b..776b8dca3 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -89,9 +89,11 @@ redesigned for Kotlin. - Authenticated friend activity is the authority for visible online, playing, world-name, and joinable state. Never promote raw Minecraft status into UI presence without a matching privacy-filtered activity response. The gateway - rejects status for unknown peers and whenever online, playing, or the current - server/world name is hidden; login remains independently admissible so a - privacy-safe join request can still succeed. + rejects status whenever online, playing, or the current server/world name is + hidden. A capability route may answer status only when all three are visible; + this must never promote an unknown or pending identity into social presence. + Login remains independently admissible so a privacy-safe join request can + still succeed. - 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`; otherwise friends see a world that cannot yet 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 index 64346887a..587a80d1c 100644 --- 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 @@ -51,12 +51,16 @@ class FriendRequestServer( override fun allowsMinecraftStatus( context: FriendControlContext, ): Boolean { - val friend = authenticatedFriend(context) ?: return false val privacy = presencePrivacy() - return friend.permissions.canSeeMyWorlds && - privacy.showOnline && - privacy.showPlaying && - privacy.showCurrentServer + 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( 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 index 44ac662d3..29e94c51a 100644 --- 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 @@ -552,6 +552,26 @@ class FriendRequestServerTest { ) } + @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, From 0207812a4d905d635c2a0c9f6353d0989f9a1c22 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 15:18:51 +0200 Subject: [PATCH 157/188] no-mistakes(review): Hardened release, invitation, and identity lifecycle paths --- .github/workflows/connect-share-release.yml | 25 ++++++++----- .github/workflows/release-repair.yml | 15 +++++++- .github/workflows/release.yml | 2 +- .../connect/share/direct/ShareInviteCodec.kt | 7 +++- .../share/direct/ShareInviteCodecTest.kt | 17 +++++++++ .../share/fabric/DirectControlPlane.kt | 8 ++++ .../share/fabric/FabricShareBootstrap.kt | 1 + .../share/fabric/PersistentDirectIngress.kt | 15 ++++++++ .../share/fabric/DirectControlPlaneTest.kt | 37 +++++++++++++++++++ 9 files changed, 114 insertions(+), 13 deletions(-) diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml index c81d1193b..9a6e7cc91 100644 --- a/.github/workflows/connect-share-release.yml +++ b/.github/workflows/connect-share-release.yml @@ -29,8 +29,6 @@ jobs: RELEASE_TYPE: ${{ inputs.release_type }} MODRINTH_PROJECT_ID: ${{ vars.CONNECT_SHARE_MODRINTH_PROJECT_ID }} CURSEFORGE_PROJECT_ID: ${{ vars.CONNECT_SHARE_CURSEFORGE_PROJECT_ID }} - MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} - CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} steps: - name: Checkout release tag @@ -87,6 +85,17 @@ jobs: done sha256sum 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 }} @@ -96,15 +105,9 @@ jobs: gh release upload "$RELEASE_TAG" dist/*.jar dist/SHA256SUMS-connect-share.txt \ --repo "$GITHUB_REPOSITORY" --clobber - - name: Verify marketplace configuration - 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: Publish verified artifacts to Modrinth + env: + MODRINTH_TOKEN: ${{ secrets.CONNECT_SHARE_MODRINTH_TOKEN }} run: | set -euo pipefail for spec in \ @@ -143,6 +146,8 @@ jobs: done - name: Publish verified artifacts to CurseForge + env: + CURSEFORGE_TOKEN: ${{ secrets.CONNECT_SHARE_CURSEFORGE_TOKEN }} run: | set -euo pipefail for spec in \ 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 00e0c1d2e..7271985e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: - 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/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 index f40f4825c..06e47794d 100644 --- 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 @@ -121,6 +121,7 @@ object ShareInviteCodec { 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( @@ -337,7 +338,11 @@ object ShareInviteCodec { connectAddress = nullableText(), peerId = text(), internetDirectEnabled = bool(), - directCandidates = List(readLength(4)) { text() }, + directCandidates = List( + readLength(4).also { + require(it <= MAX_CANDIDATE_COUNT) + }, + ) { text() }, capability = text(), displayName = if (wireVersion == LEGACY_WIRE_VERSION) { null 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 index 66b97f635..ad65e8aba 100644 --- 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 @@ -136,6 +136,23 @@ class ShareInviteCodecTest { 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, 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 index d742f549a..bd86ef964 100644 --- 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 @@ -58,4 +58,12 @@ class DirectControlPlane( 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/FabricShareBootstrap.kt b/share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/FabricShareBootstrap.kt index fb90a2c12..8109f040a 100644 --- 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 @@ -262,6 +262,7 @@ object FabricShareBootstrap { ".play.minekube.net", ) startedControlPlane.restart() + startedDirectControlPlane.restart() }, startShare = coordinator::start, stopShare = coordinator::stop, 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 index 3ca607b8c..c13c68864 100644 --- 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 @@ -98,6 +98,21 @@ class PersistentDirectIngress( } } + 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, 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 index b23d97a9a..887dcbe46 100644 --- 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 @@ -12,6 +12,7 @@ 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 @@ -74,6 +75,40 @@ class DirectControlPlaneTest { 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) @@ -105,6 +140,7 @@ class DirectControlPlaneTest { private class RecordingIngress : DirectShareIngress { var starts = 0 var closes = 0 + val startedAddresses = mutableListOf() override suspend fun start( options: ShareOptions, @@ -112,6 +148,7 @@ class DirectControlPlaneTest { connectAddress: String?, ): DirectShareHandle { starts++ + startedAddresses += connectAddress return DirectShareHandle( invitation = "minekube://share/persistent-control", lanAvailable = true, From 39fe69e3e146ff7aec2ab9579a1ee8caf3ef9793 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 16:21:04 +0200 Subject: [PATCH 158/188] no-mistakes(test): Conditioned status probing on visible world privacy --- .../share/fabric/PrismFriendJoinE2ETest.kt | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) 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 index 54f9b5e11..3e3e79282 100644 --- 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 @@ -79,23 +79,28 @@ class PrismFriendJoinE2ETest { .FriendActivityRequest(UUID.randomUUID()), ) } - assertEquals( - FriendActivityKind.HOSTING_WORLD, - activityResult.getOrNull()?.kind - ?: fail(activityResult.leftOrNull()?.safeMessage - ?: "Host returned no friend activity"), - ) + val activity = activityResult.getOrNull() + ?: fail( + activityResult.leftOrNull()?.safeMessage + ?: "Host returned no friend activity", + ) + assertEquals(FriendActivityKind.HOSTING_WORLD, activity.kind) - // Status and gameplay require different one-shot proxies. - withTimeout(30_000) { - while ( - browser.probeLan( - friend, - DirectP2pAuthMode.OFFLINE, - MinecraftStatusProbe(), - ) == null - ) { - delay(250) + // A hidden world name intentionally rejects 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( From 7465c694c5e14d092ce0ff0107271a46fad7c0dd Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 16:37:05 +0200 Subject: [PATCH 159/188] no-mistakes(document): Aligned E2E status guidance with privacy --- .agents/skills/connect-share-prism-e2e/SKILL.md | 9 ++++++--- docs/connect-share-testing.md | 7 +++++-- .../specs/2026-07-31-connect-share-prism-skill-design.md | 9 +++++---- .../connect/share/fabric/PrismFriendJoinE2ETest.kt | 6 +++--- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 59e80b409..e2f617b93 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -6,8 +6,9 @@ description: Drive and diagnose Connect Share with two real Prism Launcher clien # Connect Share Prism E2E Use the repository's opt-in live harness to prove the complete friend-to-world -flow. Treat discovery, activity, status, approval, and Minecraft login as -separate gates; success at an earlier gate never proves a later one. +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 @@ -84,7 +85,9 @@ The test must remain running while the external guest uses the port written to 1. mDNS discovers the saved confirmed friend's peer identity. 2. Authenticated friend control reports `HOSTING_WORLD`. -3. A dedicated direct proxy answers a real Minecraft status probe. +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 diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index f93c51c43..249445279 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -195,8 +195,11 @@ 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, status, approval, and a new ` joined the game` host-log -line. Startup or control-plane reachability alone does not pass. +friend activity, privacy-permitted status when the host exposes its world name, +approval, and a new ` joined the game` host-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 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 index 8451a5f22..25788fb9a 100644 --- 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 @@ -29,10 +29,11 @@ Keep `SKILL.md` concise and procedural. It will require agents to: 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, 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 because the current proxy - is one-shot. +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 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 index 3e3e79282..a8dbde9b8 100644 --- 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 @@ -86,9 +86,9 @@ class PrismFriendJoinE2ETest { ) assertEquals(FriendActivityKind.HOSTING_WORLD, activity.kind) - // A hidden world name intentionally rejects raw Minecraft - // status. Authenticated activity remains the privacy-safe - // authority, and gameplay admission is independent. + // 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) { From 59098e5d7c58a5dbd8d1e308a417acc7022b903c Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 17:07:48 +0200 Subject: [PATCH 160/188] fix(share): preserve friend routes across world discovery --- .../skills/connect-share-prism-e2e/SKILL.md | 4 ++ share/AGENTS.md | 5 ++ .../share/fabric/FabricShareBrowser.kt | 4 +- .../share/fabric/FabricShareBrowserTest.kt | 41 +++++++++++ .../share/fabric/PrismFriendJoinE2ETest.kt | 68 ++++++++++++++++--- 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index e2f617b93..eebe24afb 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -136,6 +136,10 @@ normal pending request, host approval, and one-shot admission path. - **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. + The social control peer and active-world peer share a stable share ID but + use different peer IDs, so browser discovery must retain entries by + `(shareId, peerId)`; retaining only the latest share ID makes friend status + and joins depend on mDNS event order. - **Runtime readiness:** use `jcmd GC.class_histogram` to look for `ShareState$Sharing`, `ActiveTransport`, `PublishedVanillaTransport`, and `ShareCoordinator$ActiveShare` when ordinary logs are insufficient. diff --git a/share/AGENTS.md b/share/AGENTS.md index 776b8dca3..7d27c7e59 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -128,6 +128,11 @@ redesigned for Kotlin. Prism instance copies `share-libp2p-identity.key`; simultaneously advertising that same peer identity from several processes makes mDNS routing ambiguous and can produce misleading libp2p stream failures. +- 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)`; deduplicating by share ID alone can + evict the saved friend's control route immediately after authenticated + activity and make status/join readiness appear flaky. - Manually constructed Prism Forge/NeoForge components need correct `cachedRequires` metadata and usually one online first launch to download loader libraries. Kotlin for Forge must be installed from its `-all.jar`; 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 index 70f77ea59..f0ab2f492 100644 --- 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 @@ -404,7 +404,9 @@ class FabricShareBrowser private constructor( ) mutableDiscovered.value = ( mutableDiscovered.value.filterNot { - it.invitation.payload.shareId == invitation.payload.shareId + val existing = it.invitation.payload + existing.shareId == invitation.payload.shareId && + existing.peerId == invitation.payload.peerId } + found ).takeLast(MAX_DISCOVERED_SHARES) } 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 index 4bc271b9d..9f7fb287e 100644 --- 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 @@ -144,6 +144,47 @@ class FabricShareBrowserTest { 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 `friend control uses saved direct internet route outside the LAN`() = runTest { val node = FakeGuestNode() 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 index a8dbde9b8..2b6a61b79 100644 --- 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 @@ -19,12 +19,35 @@ 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") + val absent = snapshotLog(guestLog) + Files.writeString( + guestLog, + "[old] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, absent)) + val before = snapshotLog(guestLog) + + Files.writeString( + guestLog, + "[new] [Render thread/INFO]: Loaded 41 advancements\n", + ) + + assertTrue(hasNewLoadedAdvancements(guestLog, before)) + } + @Test fun `saved friend requests and joins a live singleplayer world`() = runBlocking { @@ -44,7 +67,7 @@ class PrismFriendJoinE2ETest { val joinsBefore = Files.readString(hostLog) .lineSequence() .count { joinedLine in it } - val guestLoadsBefore = guestLog?.let(::loadedAdvancementsCount) + val guestLogBefore = guestLog?.let(::snapshotLog) val friend = FriendStore(dataDirectory).all().single() System.getenv("LIVE_HOST_DATA")?.let { hostDataValue -> val guestPeerId = DirectP2pNode( @@ -144,11 +167,13 @@ class PrismFriendJoinE2ETest { delay(100) } } - if (guestLog != null && guestLoadsBefore != null) { + if (guestLog != null && guestLogBefore != null) { withTimeout(180_000) { while ( - loadedAdvancementsCount(guestLog) <= - guestLoadsBefore + !hasNewLoadedAdvancements( + guestLog, + guestLogBefore, + ) ) { delay(100) } @@ -160,12 +185,35 @@ class PrismFriendJoinE2ETest { } } - private fun loadedAdvancementsCount(log: Path): Int = - if (Files.exists(log)) { - Files.readString(log).lineSequence().count { - "Loaded " in it && " advancements" in it - } + private fun snapshotLog(path: Path): LogSnapshot = + (if (Files.exists(path)) Files.readString(path) else "").let { contents -> + LogSnapshot( + contents = contents, + loadedAdvancements = loadedAdvancementsCount(contents), + ) + } + + private fun hasNewLoadedAdvancements( + path: Path, + before: LogSnapshot, + ): Boolean { + if (!Files.exists(path)) return false + val contents = Files.readString(path) + val current = loadedAdvancementsCount(contents) + return if (contents.startsWith(before.contents)) { + current > before.loadedAdvancements } else { - 0 + current > 0 } + } + + private fun loadedAdvancementsCount(contents: String): Int = + contents.lineSequence().count { + "Loaded " in it && " advancements" in it + } + + private data class LogSnapshot( + val contents: String, + val loadedAdvancements: Int, + ) } From d7f2fdb4974e2e0ffd985d07e21bd6a89b7f1856 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 17:33:57 +0200 Subject: [PATCH 161/188] no-mistakes(review): Hardened concurrent discovery, mDNS refresh, and rotated-log evidence --- .../tunnel/p2p/DirectP2pNodeRuntime.java | 11 ++- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 22 +++++ .../share/fabric/FabricShareBrowser.kt | 17 ++-- .../share/fabric/FabricShareBrowserTest.kt | 48 +++++++++++ .../share/fabric/PrismFriendJoinE2ETest.kt | 86 +++++++++++++++---- 5 files changed, 156 insertions(+), 28 deletions(-) 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 index fc73f067a..f1682a3b0 100644 --- a/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java +++ b/core/src/main/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeRuntime.java @@ -69,6 +69,7 @@ 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; @@ -99,7 +100,7 @@ final class DirectP2pNodeRuntime { private final PrivKey privateKey; private final List proxies = new CopyOnWriteArrayList<>(); - private final java.util.Set discoveredInvitations = + private final Set discoveredInvitations = Collections.newSetFromMap(new ConcurrentHashMap<>()); private final java.util.Set mdnsInspections = Collections.newSetFromMap(new ConcurrentHashMap<>()); @@ -472,7 +473,7 @@ private void onMdnsPeer(PeerInfo peer) { try { DirectP2pDiscoveredShare found = inspect(address, Duration.ofSeconds(3)); - if (discoveredInvitations.add(found.invitation())) { + if (shouldNotifyDiscovery(discoveredInvitations, found)) { listener.onDiscovered(found); } return; @@ -485,6 +486,12 @@ private void onMdnsPeer(PeerInfo peer) { 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"); 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 index a58f6ab33..93082a98e 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -39,7 +39,9 @@ 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; @@ -319,6 +321,26 @@ void mdnsHostNameComesFromPeerIdentityWithoutDnsResolution() { 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(); 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 index f0ab2f492..e5afab5a5 100644 --- 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 @@ -27,6 +27,7 @@ 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( @@ -402,13 +403,15 @@ class FabricShareBrowser private constructor( invitation = invitation, lanAddress = discovered.address(), ) - mutableDiscovered.value = ( - mutableDiscovered.value.filterNot { - val existing = it.invitation.payload - existing.shareId == invitation.payload.shareId && - existing.peerId == invitation.payload.peerId - } + found - ).takeLast(MAX_DISCOVERED_SHARES) + 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( 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 index 9f7fb287e..7487232d0 100644 --- 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 @@ -18,6 +18,8 @@ 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 @@ -185,6 +187,37 @@ class FabricShareBrowserTest { 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() @@ -544,6 +577,21 @@ class FabricShareBrowserTest { 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, 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 index 2b6a61b79..b20be3149 100644 --- 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 @@ -5,12 +5,15 @@ 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.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.test.fail @@ -37,15 +40,34 @@ class PrismFriendJoinE2ETest { guestLog, "[old] [Render thread/INFO]: Loaded 41 advancements\n", ) + assertFalse(hasNewLoadedAdvancements(guestLog, absent)) + + Files.writeString( + guestLog, + "[old] [Render thread/INFO]: Loaded 41 advancements\n" + + "[new] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, absent)) - val before = snapshotLog(guestLog) Files.writeString( guestLog, - "[new] [Render thread/INFO]: Loaded 41 advancements\n", + "[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", + ) + assertFalse(hasNewLoadedAdvancements(guestLog, beforeRotation)) - assertTrue(hasNewLoadedAdvancements(guestLog, before)) + Files.writeString( + guestLog, + "[startup] [Render thread/INFO]: Loaded 41 advancements\n" + + "[join] [Render thread/INFO]: Loaded 41 advancements\n", + ) + assertTrue(hasNewLoadedAdvancements(guestLog, beforeRotation)) } @Test @@ -186,25 +208,42 @@ class PrismFriendJoinE2ETest { } private fun snapshotLog(path: Path): LogSnapshot = - (if (Files.exists(path)) Files.readString(path) else "").let { contents -> - LogSnapshot( - contents = contents, - loadedAdvancements = loadedAdvancementsCount(contents), - ) - } + readLog(path) ?: LogSnapshot( + exists = false, + fileKey = null, + contents = "", + loadedAdvancements = 0, + ) private fun hasNewLoadedAdvancements( path: Path, before: LogSnapshot, ): Boolean { - if (!Files.exists(path)) return false - val contents = Files.readString(path) - val current = loadedAdvancementsCount(contents) - return if (contents.startsWith(before.contents)) { - current > before.loadedAdvancements - } else { - current > 0 + 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) + } + if (!sameFile || !current.contents.startsWith(before.contents)) { + before.replaceWith(current) + return false } + return current.loadedAdvancements > before.loadedAdvancements + } + + 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 = @@ -213,7 +252,16 @@ class PrismFriendJoinE2ETest { } private data class LogSnapshot( - val contents: String, - val loadedAdvancements: Int, - ) + var exists: Boolean, + var fileKey: Any?, + var contents: String, + var loadedAdvancements: Int, + ) { + fun replaceWith(other: LogSnapshot) { + exists = other.exists + fileKey = other.fileKey + contents = other.contents + loadedAdvancements = other.loadedAdvancements + } + } } From 3806ff9f5933a5b989eee819466481d1b56fa467 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 18:12:48 +0200 Subject: [PATCH 162/188] no-mistakes(document): Consolidated Share docs; no lint issues remain --- .agents/skills/connect-share-prism-e2e/SKILL.md | 10 ++++++---- share/AGENTS.md | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index eebe24afb..40d5dfd1f 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -80,6 +80,10 @@ LIVE_PLAYER_NAME= \ --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel ``` +The harness tracks the active log file and resets its baseline when Prism +rotates `latest.log`; keep `LIVE_GUEST_LOG` pointed at that active path and +wait for a new advancement line after a rotation. + The test must remain running while the external guest uses the port written to `LIVE_PORT_FILE`. It proves, in order: @@ -136,10 +140,8 @@ normal pending request, host approval, and one-shot admission path. - **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. - The social control peer and active-world peer share a stable share ID but - use different peer IDs, so browser discovery must retain entries by - `(shareId, peerId)`; retaining only the latest share ID makes friend status - and joins depend on mDNS event order. + 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. diff --git a/share/AGENTS.md b/share/AGENTS.md index 7d27c7e59..e19bd38af 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -130,9 +130,11 @@ redesigned for Kotlin. and can produce misleading libp2p stream failures. - 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)`; deduplicating by share ID alone can - evict the saved friend's control route immediately after authenticated - activity and make status/join readiness appear flaky. + retain one entry per `(shareId, peerId)` and refresh that entry when the same + peer advertises a changed address; deduplicating by share ID alone or + suppressing same-invitation address changes can evict the saved friend's + control route immediately after authenticated activity and make status/join + readiness appear flaky. - Manually constructed Prism Forge/NeoForge components need correct `cachedRequires` metadata and usually one online first launch to download loader libraries. Kotlin for Forge must be installed from its `-all.jar`; From d844191778789f80ec13bf0d336dc8c7c47fd0d9 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 18:20:44 +0200 Subject: [PATCH 163/188] test(share): preserve rotated join evidence --- .../skills/connect-share-prism-e2e/SKILL.md | 7 +-- .../share/fabric/PrismFriendJoinE2ETest.kt | 50 ++++++++++--------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 40d5dfd1f..1f3d06ac0 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -80,9 +80,10 @@ LIVE_PLAYER_NAME= \ --tests '*PrismFriendJoinE2ETest*' --rerun-tasks --no-parallel ``` -The harness tracks the active log file and resets its baseline when Prism -rotates `latest.log`; keep `LIVE_GUEST_LOG` pointed at that active path and -wait for a new advancement line after a rotation. +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 port written to `LIVE_PORT_FILE`. It proves, in order: 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 index b20be3149..9520fa912 100644 --- 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 @@ -13,7 +13,6 @@ import java.nio.file.attribute.BasicFileAttributes 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.test.fail @@ -35,12 +34,11 @@ class PrismFriendJoinE2ETest { @Test fun `rotated guest log counts fresh advancement evidence`() { val guestLog = tempDir.resolve("latest.log") - val absent = snapshotLog(guestLog) Files.writeString( guestLog, "[old] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertFalse(hasNewLoadedAdvancements(guestLog, absent)) + val beforeAppend = snapshotLog(guestLog) Files.writeString( guestLog, @@ -48,7 +46,7 @@ class PrismFriendJoinE2ETest { "[new] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertTrue(hasNewLoadedAdvancements(guestLog, absent)) + assertTrue(hasNewLoadedAdvancements(guestLog, beforeAppend)) Files.writeString( guestLog, @@ -60,14 +58,25 @@ class PrismFriendJoinE2ETest { guestLog, "[startup] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertFalse(hasNewLoadedAdvancements(guestLog, beforeRotation)) + 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, - "[startup] [Render thread/INFO]: Loaded 41 advancements\n" + - "[join] [Render thread/INFO]: Loaded 41 advancements\n", + "[previous] [Render thread/INFO]: Loaded 41 advancements\n", ) - assertTrue(hasNewLoadedAdvancements(guestLog, beforeRotation)) + 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 @@ -226,11 +235,11 @@ class PrismFriendJoinE2ETest { } else { current.contents.startsWith(before.contents) } - if (!sameFile || !current.contents.startsWith(before.contents)) { - before.replaceWith(current) - return false + return if (sameFile && current.contents.startsWith(before.contents)) { + current.loadedAdvancements > before.loadedAdvancements + } else { + current.loadedAdvancements > 0 } - return current.loadedAdvancements > before.loadedAdvancements } private fun readLog(path: Path): LogSnapshot? = try { @@ -252,16 +261,9 @@ class PrismFriendJoinE2ETest { } private data class LogSnapshot( - var exists: Boolean, - var fileKey: Any?, - var contents: String, - var loadedAdvancements: Int, - ) { - fun replaceWith(other: LogSnapshot) { - exists = other.exists - fileKey = other.fileKey - contents = other.contents - loadedAdvancements = other.loadedAdvancements - } - } + val exists: Boolean, + val fileKey: Any?, + val contents: String, + val loadedAdvancements: Int, + ) } From ba4614fb8817cfd9588553d05824be7edda93870 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Sun, 2 Aug 2026 18:54:06 +0200 Subject: [PATCH 164/188] no-mistakes(document): Aligned Prism two-client evidence guidance --- docs/connect-share-testing.md | 9 +++++---- share/AGENTS.md | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 249445279..287e90b6f 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -196,10 +196,11 @@ 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 new ` joined the game` host-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. +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 diff --git a/share/AGENTS.md b/share/AGENTS.md index e19bd38af..ed47cc53d 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -74,9 +74,10 @@ redesigned for Kotlin. `--offline ` is authoritative; editing `InstanceAccountId` while Prism runs is not, because Prism rewrites it. - Prove the flow in layers: mDNS discovery, authenticated friend activity, - Minecraft status when host privacy permits it, then a real login whose host log contains - ` joined the game`. Control-plane reachability or a status response does - not prove that the world is joinable. `dns-sd -B + Minecraft status when host privacy permits it, then follow [the testing + guide](../docs/connect-share-testing.md) for the real two-client login + evidence gates. Control-plane reachability or a status response does not + prove that the world is joinable. `dns-sd -B _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are useful diagnostics for discovery and live `ShareState`/transport objects. - Run only one Gradle invocation at a time in a worktree. Concurrent test tasks @@ -119,9 +120,10 @@ redesigned for Kotlin. permission afterwards. Keep machine-specific instance paths and credentials in environment variables, never in committed tests or scripts. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, - supply `LIVE_DATA`, `LIVE_PORT_FILE`, and `LIVE_HOST_LOG`, then launch the - guest against the port written to `LIVE_PORT_FILE`. The test succeeds only - after the host logs a new ` joined the game` line. + then follow [the testing guide](../docs/connect-share-testing.md) for the + complete two-client launch and evidence gates. Keep machine-specific paths + in `LIVE_DATA`, `LIVE_PORT_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` + environment variables. - Invoke the live harness with `--rerun-tasks`. Its environment variables are intentionally not task inputs, so an up-to-date result is not live evidence. - Keep only one host and one guest identity active during a live run. Cloning a From dc147291109249504b70c253e02b7906573fe531 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:09:58 +0200 Subject: [PATCH 165/188] docs(share): map universal party acceptance evidence --- docs/connect-share-adoption-evidence.md | 98 +++++++++ ...08-02-connect-share-adoption-foundation.md | 194 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 docs/connect-share-adoption-evidence.md create mode 100644 docs/plans/2026-08-02-connect-share-adoption-foundation.md diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md new file mode 100644 index 000000000..0c00f052a --- /dev/null +++ b/docs/connect-share-adoption-evidence.md @@ -0,0 +1,98 @@ +# 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. +- **Gap**: code or focused coverage is incomplete. The issue must remain open. + +## Evidence baseline + +- Commit under test: `6073f2f6101d86d38c71e517148725fd2c089c82`. +- 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. + +## #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 required | `AdmissionControllerTest` (`approved friend request authorizes exactly one following gameplay join`) and `FriendJoinOrchestratorTest` (`shared world opens gameplay only after approval`) | Record fresh two-client host/guest login evidence on the exact artifact | +| 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 | Gap | `CompatibilityProfile.fingerprint()`, filtered profile transport in `FriendControlWire`, and compatibility-before-approval ordering in `FriendJoinOrchestratorTest` | Add a focused wire-level assertion that the fingerprint is carried and validated before admission | +| 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 recovery screen | +| Copy or link matching Modrinth or CurseForge pack metadata | Gap | `LoadedCompatibilityProfileFactory` accepts safe HTTPS metadata and recognizes both platforms; only Modrinth has focused coverage | Add CurseForge and unsafe-link coverage, then prove the rendered copy/open action | +| 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 | + +## #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` documents **Copy server address** and adapter artifact vocabulary asserts the friends-first UI | Copy it on the exact host artifact and join from a profile without Connect Share | +| 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | +| 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 | Gap | invitation expiry and host-denial translation keys exist in `ShareUiMessageTest`; no focused no-mod assertion covers the complete distinction | Add no-mod admission outcome coverage and inspect the vanilla disconnect copy | + +## #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` (`offline reconnect with copied name requires a new approval`) | 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 | Gap | `FollowNextSessionControllerTest` covers expiry, cancellation, reconnect, removal through confirmed-set loss, duplicates, and simultaneous follow | Add an explicit blocked-relationship regression and verify the packaged cancel notification | + +## Open foundation gaps + +The baseline intentionally leaves #95, #96, #99, #100, and #103 open. The +next TDD slice starts with the three explicit automated gaps above, then uses +the exact-head Prism harness for product proof. Minecraft UI clicks are never +automated; any irreducible approval interaction is recorded as a human +checkpoint with all other evidence gathered noninteractively. 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..8b6bbc8af --- /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. + +- [ ] **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. From bad737b574ac9c1f45a09e25caeca17b46965adc Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:18:26 +0200 Subject: [PATCH 166/188] fix(share): make joins and follow cancellation actionable --- .../connect/share/friend/FriendControlWire.kt | 15 ++++++- .../share/friend/FriendControlWireTest.kt | 41 +++++++++++++++++ .../fabric/v1_20_1/ConnectShare12111Client.kt | 19 ++++---- .../v1_20_1/Minecraft12111LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../fabric/v1_21_1/ConnectShare12111Client.kt | 19 ++++---- .../v1_21_1/Minecraft12111LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../v1_21_11/ConnectShare12111Client.kt | 19 ++++---- .../v1_21_11/Minecraft12111LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../fabric/v26_2/ConnectShare262Client.kt | 19 ++++---- .../fabric/v26_2/Minecraft262LoginBridge.kt | 6 +-- .../assets/connect-share/lang/de_de.json | 2 + .../assets/connect-share/lang/en_us.json | 2 + .../fabric/ui/ShareScreenPresentation.kt | 23 ++++++++++ .../connect/share/fabric/ui/ShareUiMessage.kt | 37 +++++++++++----- .../fabric/FollowNextSessionControllerTest.kt | 23 ++++++++++ .../LoadedCompatibilityProfileFactoryTest.kt | 44 +++++++++++++++++++ .../fabric/ui/ShareScreenPresentationTest.kt | 14 ++++++ .../share/fabric/ui/ShareUiMessageTest.kt | 25 ++++++++--- 24 files changed, 278 insertions(+), 60 deletions(-) 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 index 4ed1992e3..7807b1d6e 100644 --- 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 @@ -98,6 +98,7 @@ object FriendControlWire { 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 @@ -475,6 +476,7 @@ object FriendControlWire { 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) @@ -596,6 +598,15 @@ object FriendControlWire { } 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() @@ -620,12 +631,14 @@ object FriendControlWire { ) else -> invalid() } - return CompatibilityProfile( + val profile = CompatibilityProfile( minecraftVersion = minecraftVersion, loader = loader, requiredMods = mods, pack = pack, ) + ensure(profile.fingerprint() == expectedFingerprint) + return profile } fun ensure(condition: Boolean) { 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 index 22846f7d1..87b5a9fee 100644 --- 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 @@ -6,6 +6,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertTrue class FriendControlWireTest { @Test @@ -88,6 +89,38 @@ class FriendControlWireTest { } } + @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) @@ -190,6 +223,14 @@ class FriendControlWireTest { 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) 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 index 3a5a0a85f..a58bf53cb 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -371,14 +372,16 @@ class ConnectShare1201Runtime( action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index 463750eb4..e450b370b 100644 --- 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 @@ -126,8 +126,8 @@ object Minecraft1201LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -170,5 +170,5 @@ object Minecraft1201LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index ddd260c03..92c4755c8 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -371,14 +372,16 @@ class ConnectShare1211Runtime( action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index 1ff90a1c3..c21fd54d4 100644 --- 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 @@ -127,8 +127,8 @@ object Minecraft1211LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -171,5 +171,5 @@ object Minecraft1211LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index d2cd5b0ed..999123815 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -387,14 +388,16 @@ class ConnectShare12111Client : ClientModInitializer { action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index a6235397d..a2ac1224d 100644 --- 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 @@ -127,8 +127,8 @@ object Minecraft12111LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -171,5 +171,5 @@ object Minecraft12111LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index 6b7a47a28..be982c1b8 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -387,14 +388,16 @@ class ConnectShare262Client : ClientModInitializer { action.displayName, ) - is FollowAction.Expired -> followToast( - minecraft, - "connect_share.notification.follow_expired", - "connect_share.notification.follow_expired_detail", - action.displayName, - ) - - is FollowAction.Cancelled -> Unit + is FollowAction.Expired, + is FollowAction.Cancelled -> + checkNotNull(action.terminalNotification()).let { + followToast( + minecraft, + it.titleKey, + it.detailKey, + it.displayName, + ) + } } } } 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 index c7e5a3b32..870c927e4 100644 --- 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 @@ -127,8 +127,8 @@ object Minecraft262LoginBridge { ).onLeft { server.execute { deny.accept( - Component.translatable( - ShareLoginMessages.AUTHENTICATION_REQUIRED, + Component.literal( + ShareLoginMessages.AUTHENTICATION_REQUIRED.fallback, ), ) } @@ -171,5 +171,5 @@ object Minecraft262LoginBridge { } private fun denialReason(answer: AdmissionAnswer?): Component = - Component.translatable(ShareLoginMessages.denial(answer)) + Component.literal(ShareLoginMessages.denial(answer).fallback) } 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 index f39c01fbb..7dfcccfa4 100644 --- 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 @@ -74,6 +74,8 @@ "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", 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 index 681973eee..f12444576 100644 --- 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 @@ -87,6 +87,8 @@ "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", 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 index 61e947b26..7f995d736 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -57,6 +58,28 @@ data class CompatibilityLine( 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 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 index e5cd4beba..c60e50432 100644 --- 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 @@ -15,18 +15,35 @@ data class ShareUiMessage( val arguments: List = emptyList(), ) +data class RemoteLoginMessage( + val translationKey: String, + val fallback: String, +) + object ShareLoginMessages { - const val AUTHENTICATION_REQUIRED = - "connect_share.login.authentication_required" + val AUTHENTICATION_REQUIRED = RemoteLoginMessage( + "connect_share.login.authentication_required", + "This connection needs a valid Minecraft account.", + ) - fun denial(answer: AdmissionAnswer?): String = when (answer) { - AdmissionAnswer.TIMEOUT -> - "connect_share.login.approval_timed_out" - AdmissionAnswer.CAPACITY -> - "connect_share.login.share_full" - AdmissionAnswer.STOPPED -> - "connect_share.login.sharing_stopped" - else -> "connect_share.login.host_denied" + 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.", + ) } } 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 index ea9e961a9..c94cbec91 100644 --- 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 @@ -102,6 +102,29 @@ class FollowNextSessionControllerTest { 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 }) 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 index 8defd857e..05f7e3757 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -46,4 +47,47 @@ class LoadedCompatibilityProfileFactoryTest { 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/ui/ShareScreenPresentationTest.kt b/share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/ShareScreenPresentationTest.kt index 418718cc0..d927958ac 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -167,6 +168,19 @@ class ShareScreenPresentationTest { 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, 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 index 93a536d3d..f5522815f 100644 --- 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 @@ -48,23 +48,38 @@ class ShareUiMessageTest { @Test fun `login denial messages are stable translation keys`() { assertEquals( - "connect_share.login.authentication_required", + RemoteLoginMessage( + "connect_share.login.authentication_required", + "This connection needs a valid Minecraft account.", + ), ShareLoginMessages.AUTHENTICATION_REQUIRED, ) assertEquals( - "connect_share.login.approval_timed_out", + RemoteLoginMessage( + "connect_share.login.approval_timed_out", + "The host did not approve this join in time. Try again.", + ), ShareLoginMessages.denial(AdmissionAnswer.TIMEOUT), ) assertEquals( - "connect_share.login.share_full", + RemoteLoginMessage( + "connect_share.login.share_full", + "This shared world is full. Ask the host to make room.", + ), ShareLoginMessages.denial(AdmissionAnswer.CAPACITY), ) assertEquals( - "connect_share.login.sharing_stopped", + 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( - "connect_share.login.host_denied", + RemoteLoginMessage( + "connect_share.login.host_denied", + "The host declined this join. Request access again when ready.", + ), ShareLoginMessages.denial(AdmissionAnswer.DENY), ) } From 8b72a5c6216a7410842005086160afc1b5cc5e85 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:19:04 +0200 Subject: [PATCH 167/188] docs(share): record foundation gap fixes --- docs/connect-share-adoption-evidence.md | 29 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 0c00f052a..c70165410 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -19,13 +19,21 @@ Status meanings: ## Evidence baseline -- Commit under test: `6073f2f6101d86d38c71e517148725fd2c089c82`. +- Original acceptance-audit commit: + `6073f2f6101d86d38c71e517148725fd2c089c82`. +- Current deterministic head: + `9397658c11dfff381763492954e900b1a09ec57f`. - 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. ## #95 — one-click presence, request, approval, and join @@ -44,11 +52,11 @@ Status meanings: | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| -| Exchange a privacy-safe compatibility fingerprint before admission | Gap | `CompatibilityProfile.fingerprint()`, filtered profile transport in `FriendControlWire`, and compatibility-before-approval ordering in `FriendJoinOrchestratorTest` | Add a focused wire-level assertion that the fingerprint is carried and validated before admission | +| 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 recovery screen | -| Copy or link matching Modrinth or CurseForge pack metadata | Gap | `LoadedCompatibilityProfileFactory` accepts safe HTTPS metadata and recognizes both platforms; only Modrinth has focused coverage | Add CurseForge and unsafe-link coverage, then prove the rendered copy/open action | +| 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 | @@ -62,7 +70,7 @@ Status meanings: | 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | | 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 | Gap | invitation expiry and host-denial translation keys exist in `ShareUiMessageTest`; no focused no-mod assertion covers the complete distinction | Add no-mod admission outcome coverage and inspect the vanilla disconnect copy | +| Errors distinguish unavailable host from invalid or expired admission | Product proof required | `RemoteLoginMessage` gives vanilla-readable fallback text for stopped, timed-out, full, denied, and invalid-auth cases; `ShareUiMessageTest` fixes their distinct contracts | Inspect each fallback on a vanilla Direct Connect client | ## #100 — privacy, permissions, and relationship safety @@ -87,12 +95,13 @@ Status meanings: | 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 | Gap | `FollowNextSessionControllerTest` covers expiry, cancellation, reconnect, removal through confirmed-set loss, duplicates, and simultaneous follow | Add an explicit blocked-relationship regression and verify the packaged cancel notification | +| 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 | ## Open foundation gaps -The baseline intentionally leaves #95, #96, #99, #100, and #103 open. The -next TDD slice starts with the three explicit automated gaps above, then uses -the exact-head Prism harness for product proof. Minecraft UI clicks are never -automated; any irreducible approval interaction is recorded as a human -checkpoint with all other evidence gathered noninteractively. +The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the +remaining exact-head product claims are observed. The deterministic gaps found +in the first audit are fixed in `9397658c`; the next step is the Prism product +pass. Minecraft UI clicks are never automated; any irreducible approval +interaction is recorded as a human checkpoint with all other evidence gathered +noninteractively. From 32cc68436000413dc8bb117a131230f1d1b1758c Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:32:47 +0200 Subject: [PATCH 168/188] fix(share): preserve actionable no-mod admission errors --- .../skills/connect-share-prism-e2e/SKILL.md | 6 ++++ share/AGENTS.md | 4 +++ .../fabric/FabricSessionAdmissionGate.kt | 25 +++++++++++--- .../fabric/FabricSessionAdmissionGateTest.kt | 33 ++++++++++++++++++- .../fabric/FriendPairingDirectE2ETest.kt | 7 ++-- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 1f3d06ac0..ae26a353f 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -169,6 +169,12 @@ Recognize these established failure signatures: - 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. ## Finish and retain knowledge diff --git a/share/AGENTS.md b/share/AGENTS.md index ed47cc53d..cf213164e 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -119,6 +119,10 @@ redesigned for Kotlin. the confirmed test friend, send the real libp2p join request, and restore the permission afterwards. Keep machine-specific instance paths and credentials in environment variables, never in committed tests or scripts. +- Connect's no-mod session admission must finish before vanilla's own + connection timeout. Preserve a deadline buffer, cancel the pending host + request when it expires, and test the guest-visible actionable denial; + generic `Timed out` is a failed UX result. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, then follow [the testing guide](../docs/connect-share-testing.md) for the complete two-client launch and evidence gates. Keep machine-specific paths 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 index a3176ce52..7f143da68 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -16,11 +17,14 @@ 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, @@ -28,10 +32,17 @@ class FabricSessionAdmissionGate( 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 { @@ -62,7 +73,9 @@ class FabricSessionAdmissionGate( lateinit var job: Job job = scope.launch(start = CoroutineStart.LAZY) { try { - val answer = admission.request(identity) + val answer = withTimeoutOrNull(decisionTimeout) { + admission.request(identity) + } ?: AdmissionAnswer.TIMEOUT approvedJoins.record(identity, answer) future.complete(answer.toCoreDecision()) } catch (cancellation: CancellationException) { @@ -133,10 +146,12 @@ class FabricSessionAdmissionGate( private fun AdmissionAnswer.toCoreDecision(): SessionAdmissionDecision = when (this) { AdmissionAnswer.ALLOW -> SessionAdmissionDecision.allow() - AdmissionAnswer.DENY -> SessionAdmissionDecision.deny("Host denied this connection") - AdmissionAnswer.TIMEOUT -> SessionAdmissionDecision.deny("Host approval timed out") - AdmissionAnswer.STOPPED -> SessionAdmissionDecision.deny("Sharing stopped") - AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny("Share is full") + AdmissionAnswer.DENY, + AdmissionAnswer.TIMEOUT, + AdmissionAnswer.STOPPED, + AdmissionAnswer.CAPACITY -> SessionAdmissionDecision.deny( + ShareLoginMessages.denial(this).fallback, + ) } private companion object { 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 index a9a0f6fe9..be1defab6 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -134,9 +135,39 @@ class FabricSessionAdmissionGateTest { val decision = result.getNow(null) assertFalse(decision.isAllowed) assertFalse(decision.isDeferredToLocalLogin) - assertEquals("Host denied this connection", decision.safeMessage) + 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() 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 index 0634266a4..30729ce9b 100644 --- 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 @@ -37,6 +37,7 @@ 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 @@ -134,7 +135,7 @@ class FriendPairingDirectE2ETest { now = { now }, ioDispatcher = Dispatchers.IO, ) - var received = false + val received = CompletableDeferred() val result = async { pairing.send( invitation = direct.invitation, @@ -152,7 +153,7 @@ class FriendPairingDirectE2ETest { DirectP2pAuthMode.OFFLINE, ) }, - onReceived = { received = true }, + onReceived = { received.complete(Unit) }, ) } @@ -161,7 +162,7 @@ class FriendPairingDirectE2ETest { .first { it.isNotEmpty() } .single() } - assertTrue(received) + withTimeout(5.seconds) { received.await() } admission.answer(pending.requestId, allow = true) assertTrue(result.await().isRight()) From f35c55fc45ae489e336838154a1c4ee291bfee3c Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:40:03 +0200 Subject: [PATCH 169/188] test(share): record exact-head Prism evidence --- docs/connect-share-adoption-evidence.md | 33 +++++++++++++++---- ...08-02-connect-share-adoption-foundation.md | 2 +- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index c70165410..5e5b589c5 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -15,14 +15,16 @@ Status meanings: - **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`. -- Current deterministic head: - `9397658c11dfff381763492954e900b1a09ec57f`. +- Current source head for product probes: + `73f306ff84fbf0e8d24426945e6cfd813cc14301`. - 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`. @@ -34,6 +36,22 @@ Status meanings: `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. ## #95 — one-click presence, request, approval, and join @@ -43,7 +61,7 @@ Status meanings: | 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 required | `AdmissionControllerTest` (`approved friend request authorizes exactly one following gameplay join`) and `FriendJoinOrchestratorTest` (`shared world opens gameplay only after approval`) | Record fresh two-client host/guest login evidence on the exact artifact | +| 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 | @@ -64,13 +82,13 @@ Status meanings: | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| -| Host copies a short ordinary Minecraft server address | Product proof required | `docs/connect-share.md` documents **Copy server address** and adapter artifact vocabulary asserts the friends-first UI | Copy it on the exact host artifact and join from a profile without Connect Share | +| 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 reached Connecting through the ordinary public address | Inspect the copy action, then resolve the external Connect forwarding boundary and complete a vanilla join | | 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | | 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 required | `RemoteLoginMessage` gives vanilla-readable fallback text for stopped, timed-out, full, denied, and invalid-auth cases; `ShareUiMessageTest` fixes their distinct contracts | Inspect each fallback on a vanilla Direct Connect client | +| Errors distinguish unavailable host from invalid or expired admission | Product proof required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; the first product probe reproduced generic `Timed out` and drove the fix | The Connect edge must deliver a session before the rebuilt denial can be observed on vanilla | ## #100 — privacy, permissions, and relationship safety @@ -101,7 +119,8 @@ Status meanings: The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the remaining exact-head product claims are observed. The deterministic gaps found -in the first audit are fixed in `9397658c`; the next step is the Prism product -pass. Minecraft UI clicks are never automated; any irreducible approval +in the first audit are fixed in `9397658c`; the direct Prism join is proven and +the no-mod attempt is now blocked specifically at external Connect session +forwarding. Minecraft UI clicks are never automated; any irreducible approval interaction is recorded as a human checkpoint with all other evidence gathered noninteractively. diff --git a/docs/plans/2026-08-02-connect-share-adoption-foundation.md b/docs/plans/2026-08-02-connect-share-adoption-foundation.md index 8b6bbc8af..a96ce1936 100644 --- a/docs/plans/2026-08-02-connect-share-adoption-foundation.md +++ b/docs/plans/2026-08-02-connect-share-adoption-foundation.md @@ -117,7 +117,7 @@ git commit -m "docs(share): map universal party acceptance evidence" - 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. -- [ ] **Step 1: Build and hash the exact artifact** +- [x] **Step 1: Build and hash the exact artifact** Run: From 10baccb3e8f8ecbecaef39f501b42140bb523dfa Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 2 Aug 2026 23:57:27 +0200 Subject: [PATCH 170/188] feat(share): add encrypted social recovery archive --- ...-08-02-connect-share-encrypted-recovery.md | 66 +++ .../connect/share/recovery/RecoveryArchive.kt | 346 +++++++++++++ .../connect/share/recovery/RecoveryStore.kt | 468 ++++++++++++++++++ .../share/recovery/RecoveryArchiveTest.kt | 154 ++++++ .../share/recovery/RecoveryStoreTest.kt | 252 ++++++++++ 5 files changed, 1286 insertions(+) create mode 100644 docs/plans/2026-08-02-connect-share-encrypted-recovery.md create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryArchive.kt create mode 100644 share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryArchiveTest.kt create mode 100644 share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt 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..1668f85a9 --- /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` + +- [ ] 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. +- [ ] 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. +- [ ] Run file/crypto work on IO dispatchers; render only typed summaries and safe localizable errors. +- [ ] 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. +- [ ] 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 + +- [ ] 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/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..1589e3eb3 --- /dev/null +++ b/share/common/src/main/kotlin/com/minekube/connect/share/recovery/RecoveryStore.kt @@ -0,0 +1,468 @@ +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 encrypted = try { + if ( + Files.isSymbolicLink(source) || + !Files.isRegularFile(source, NOFOLLOW_LINKS) || + Files.size(source) > RecoveryArchive.MAX_ARCHIVE_BYTES + ) { + return@synchronized RecoveryStoreError.BackupReadFailed.left() + } + Files.readAllBytes(source) + } catch (_: IOException) { + return@synchronized RecoveryStoreError.BackupReadFailed.left() + } catch (_: SecurityException) { + return@synchronized RecoveryStoreError.BackupReadFailed.left() + } + + val entries = try { + val decrypted = archive.decrypt(encrypted, passphrase) + val failure = decrypted.leftOrNull() + if (failure != null) { + return@synchronized RecoveryStoreError.ArchiveFailure(failure).left() + } + decrypted.getOrNull()!! + } finally { + encrypted.fill(0) + } + + 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) } + } + } + + 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) + 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/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..aa3063045 --- /dev/null +++ b/share/common/src/test/kotlin/com/minekube/connect/share/recovery/RecoveryStoreTest.kt @@ -0,0 +1,252 @@ +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 `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 `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() + } +} From e63d9197884a23ac1bc6e354da0a9bedba768c6d Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:15:59 +0200 Subject: [PATCH 171/188] feat(share): add safe friend backup UX --- .../skills/connect-share-prism-e2e/SKILL.md | 8 + docs/connect-share-adoption-evidence.md | 21 +- docs/connect-share.md | 32 ++ ...-08-02-connect-share-encrypted-recovery.md | 12 +- share/AGENTS.md | 6 + .../connect/share/recovery/RecoveryStore.kt | 76 ++-- .../share/recovery/RecoveryStoreTest.kt | 34 ++ .../share/fabric/v1_20_1/RecoveryScreen.kt | 275 +++++++++++++++ .../fabric/v1_20_1/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/v1_21_1/RecoveryScreen.kt | 275 +++++++++++++++ .../fabric/v1_21_1/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/v1_21_11/RecoveryScreen.kt | 276 +++++++++++++++ .../fabric/v1_21_11/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/v26_2/RecoveryScreen.kt | 276 +++++++++++++++ .../share/fabric/v26_2/SharePrivacyScreen.kt | 19 +- .../assets/connect-share/lang/de_de.json | 28 +- .../assets/connect-share/lang/en_us.json | 28 +- .../share/fabric/ConnectShareClient.kt | 7 + .../share/fabric/FabricShareBootstrap.kt | 14 + .../fabric/recovery/RecoveryViewModel.kt | 325 ++++++++++++++++++ .../fabric/recovery/RecoveryViewModelTest.kt | 215 ++++++++++++ 27 files changed, 2101 insertions(+), 51 deletions(-) create mode 100644 share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/RecoveryScreen.kt create mode 100644 share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/RecoveryScreen.kt create mode 100644 share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/RecoveryScreen.kt create mode 100644 share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/RecoveryScreen.kt create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModel.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/recovery/RecoveryViewModelTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index ae26a353f..be34cb0b6 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -176,6 +176,14 @@ Recognize these established failure signatures: 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: diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 5e5b589c5..3c1eb4096 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -52,6 +52,14 @@ Status meanings: `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. +- 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. ## #95 — one-click presence, request, approval, and join @@ -73,7 +81,7 @@ Status meanings: | 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 recovery screen | +| 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 | @@ -115,6 +123,17 @@ Status meanings: | 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 | +## #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 diff --git a/docs/connect-share.md b/docs/connect-share.md index b2c7b4b00..2d6fbc2d6 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -79,6 +79,38 @@ 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 diff --git a/docs/plans/2026-08-02-connect-share-encrypted-recovery.md b/docs/plans/2026-08-02-connect-share-encrypted-recovery.md index 1668f85a9..2d2d1cbb7 100644 --- a/docs/plans/2026-08-02-connect-share-encrypted-recovery.md +++ b/docs/plans/2026-08-02-connect-share-encrypted-recovery.md @@ -48,11 +48,11 @@ - Modify: each supported Fabric settings/friends adapter and `en_us.json`/`de_de.json` - Modify: `docs/connect-share.md` -- [ ] 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. -- [ ] 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. -- [ ] Run file/crypto work on IO dispatchers; render only typed summaries and safe localizable errors. -- [ ] 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. -- [ ] 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. +- [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 @@ -60,7 +60,7 @@ - Modify: `docs/connect-share-adoption-evidence.md` - Modify: `.agents/skills/connect-share-prism-e2e/SKILL.md` and `share/AGENTS.md` only for reusable discoveries -- [ ] Build all supported artifacts and assert recovery strings/entrypoints are packaged where applicable. +- [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/share/AGENTS.md b/share/AGENTS.md index cf213164e..f773cac47 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -154,3 +154,9 @@ redesigned for Kotlin. persistent label; split pause-menu buttons must keep copy within their 100-pixel logical width. The repository Prism skill owns the capture and focus-order procedure. +- Recovery export/import must run only against the fixed Share allowlist and + while sharing is stopped. A selected 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 test simulated interruption. Never + print archive paths, contents, passwords, identities, or tokens as evidence. 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 index 1589e3eb3..78fbf07ab 100644 --- 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 @@ -104,30 +104,9 @@ class RecoveryStore( return@synchronized RecoveryStoreError.UnsafeMaterial.left() } - val encrypted = try { - if ( - Files.isSymbolicLink(source) || - !Files.isRegularFile(source, NOFOLLOW_LINKS) || - Files.size(source) > RecoveryArchive.MAX_ARCHIVE_BYTES - ) { - return@synchronized RecoveryStoreError.BackupReadFailed.left() - } - Files.readAllBytes(source) - } catch (_: IOException) { - return@synchronized RecoveryStoreError.BackupReadFailed.left() - } catch (_: SecurityException) { - return@synchronized RecoveryStoreError.BackupReadFailed.left() - } - - val entries = try { - val decrypted = archive.decrypt(encrypted, passphrase) - val failure = decrypted.leftOrNull() - if (failure != null) { - return@synchronized RecoveryStoreError.ArchiveFailure(failure).left() - } - decrypted.getOrNull()!! - } finally { - encrypted.fill(0) + val entries = when (val loaded = readArchiveEntries(source, passphrase)) { + is Either.Left -> return@synchronized loaded + is Either.Right -> loaded.value } try { @@ -145,6 +124,47 @@ class RecoveryStore( } } + 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 { @@ -255,6 +275,14 @@ class RecoveryStore( 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") } 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 index aa3063045..d5d01f48a 100644 --- 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 @@ -63,6 +63,19 @@ class RecoveryStoreTest { ) } + @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() @@ -117,6 +130,27 @@ class RecoveryStoreTest { 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() 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/SharePrivacyScreen.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/SharePrivacyScreen.kt index c49e7a8c5..d921fd2ae 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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/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/SharePrivacyScreen.kt b/share/fabric-1.21.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_1/SharePrivacyScreen.kt index 7816eed26..29264408b 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft!!.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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/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/SharePrivacyScreen.kt b/share/fabric-1.21.11/src/main/kotlin/com/minekube/connect/share/fabric/v1_21_11/SharePrivacyScreen.kt index 61cd7199b..a56ffd107 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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/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/SharePrivacyScreen.kt b/share/fabric-26.2/src/main/kotlin/com/minekube/connect/share/fabric/v26_2/SharePrivacyScreen.kt index 4a04dd306..deac19b50 100644 --- 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 @@ -76,6 +76,7 @@ class SharePrivacyScreen( ).setMaxWidth(layout.contentWidth).setCentered(true), ) + val third = (layout.contentWidth - 12) / 3 addRenderableWidget( Button.builder( Component.translatable( @@ -94,7 +95,7 @@ class SharePrivacyScreen( }.bounds( layout.contentX, layout.footerTop, - layout.halfButtonWidth, + third, 20, ).build(), ) @@ -108,9 +109,21 @@ class SharePrivacyScreen( ) { minecraft.gui.setScreen(BlockedFriendsScreen(this)) }.bounds( - layout.contentX + layout.halfButtonWidth + 6, + layout.contentX + third + 6, layout.footerTop, - layout.halfButtonWidth, + 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(), ) 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 index 7dfcccfa4..bf44f3379 100644 --- 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 @@ -256,5 +256,31 @@ "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.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 index f12444576..66fb81293 100644 --- 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 @@ -256,5 +256,31 @@ "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.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-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 index 901231dfd..1a1bf347b 100644 --- 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 @@ -6,6 +6,7 @@ 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) @@ -22,6 +23,7 @@ fun interface ConnectShareGuestScreenFactory { data class ConnectShareInstallation( val viewModel: ShareViewModel, val friendsViewModel: FriendsViewModel, + val recoveryViewModel: RecoveryViewModel, val runtime: ConnectShareRuntime, val friendCardIssuer: FriendCardIssuer, val friendCardReceiver: FriendCardReceiver, @@ -123,6 +125,10 @@ object ConnectShareClient { fun friendsViewModel(): FriendsViewModel = checkNotNull(installation).friendsViewModel + @JvmStatic + fun recoveryViewModel(): RecoveryViewModel = + checkNotNull(installation).recoveryViewModel + @JvmStatic fun friendCardIssuer(): FriendCardIssuer = checkNotNull(installation).friendCardIssuer @@ -178,6 +184,7 @@ object ConnectShareClient { friendCardConsent.cancel() guestLease.close() installation?.let { installed -> + installed.recoveryViewModel.close() installed.runtime.shutdown() installed.directControlPlane.shutdown() installed.controlPlane.shutdown() 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 index 8109f040a..232215398 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,8 @@ 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 @@ -22,6 +25,7 @@ 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 @@ -316,6 +320,15 @@ object FabricShareBootstrap { } }, ) + val recoveryViewModel = RecoveryViewModel( + scope = scope, + actions = StoredRecoveryUiActions( + RecoveryStore(dataDirectory), + ), + restoreAllowed = { + viewModel.state.value.shareState is ShareState.Idle + }, + ) val activityMonitor = FriendActivityMonitor( store = friendStore, query = { friend -> @@ -367,6 +380,7 @@ object FabricShareBootstrap { return ConnectShareInstallation( viewModel = viewModel, friendsViewModel = friendsViewModel, + recoveryViewModel = recoveryViewModel, runtime = runtime, friendCardIssuer = friendCardIssuer, friendCardReceiver = friendCardReceiver, 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/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, + ) + } +} From f1cc4b7ab9c6fc855d6070ccb5a0f8b043b698e7 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:39:23 +0200 Subject: [PATCH 172/188] build(share): reduce packaged runtime size safely --- .../connect.shadow-conventions.gradle.kts | 4 + share/AGENTS.md | 7 ++ share/fabric-1.20.1/build.gradle.kts | 4 +- share/fabric-1.21.1/build.gradle.kts | 4 +- share/fabric-1.21.11/build.gradle.kts | 4 +- share/fabric-26.2/build.gradle.kts | 4 +- .../fabric/v26_2/Fabric262ArtifactTest.kt | 114 +++++++++++++++++- share/forge-1.20.1/build.gradle.kts | 4 +- share/neoforge-1.21.1/build.gradle.kts | 4 +- 9 files changed, 136 insertions(+), 13 deletions(-) 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 64d66a52d..bd54037f9 100644 --- a/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/connect.shadow-conventions.gradle.kts @@ -58,6 +58,10 @@ tasks { "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") { diff --git a/share/AGENTS.md b/share/AGENTS.md index f773cac47..429ad2e9e 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -160,3 +160,10 @@ redesigned for Kotlin. and decrypt the entire archive before replacement, keep rollback material until a committed marker is durable, and test simulated interruption. 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 through reflection and DSL entry points that static minimization does + not see. Any payload-size reduction must keep cross-platform natives and be + proved by constructing, starting, publishing, and inspecting between two + peers loaded from the exact packaged artifact. A constructor-only classloader + test is insufficient. diff --git a/share/fabric-1.20.1/build.gradle.kts b/share/fabric-1.20.1/build.gradle.kts index 1ff9e8802..c945f99d9 100644 --- a/share/fabric-1.20.1/build.gradle.kts +++ b/share/fabric-1.20.1/build.gradle.kts @@ -154,9 +154,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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.21.1/build.gradle.kts b/share/fabric-1.21.1/build.gradle.kts index 8b962a75f..02b516616 100644 --- a/share/fabric-1.21.1/build.gradle.kts +++ b/share/fabric-1.21.1/build.gradle.kts @@ -152,9 +152,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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.11/build.gradle.kts b/share/fabric-1.21.11/build.gradle.kts index 2f992cacb..21e627bd6 100644 --- a/share/fabric-1.21.11/build.gradle.kts +++ b/share/fabric-1.21.11/build.gradle.kts @@ -151,9 +151,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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-26.2/build.gradle.kts b/share/fabric-26.2/build.gradle.kts index 1bc566ca0..0334c3fca 100644 --- a/share/fabric-26.2/build.gradle.kts +++ b/share/fabric-26.2/build.gradle.kts @@ -152,9 +152,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + 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 90 MiB release budget ($bytes bytes)" } + 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/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 index a31c88553..4d624a02b 100644 --- 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 @@ -5,9 +5,11 @@ 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.net.URLClassLoader +import java.time.Duration import java.util.jar.JarInputStream import java.util.jar.JarFile import kotlin.io.path.name @@ -159,6 +161,13 @@ class Fabric262ArtifactTest { 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, @@ -166,6 +175,17 @@ class Fabric262ArtifactTest { } } + @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 -> @@ -259,6 +279,97 @@ class Fabric262ArtifactTest { } } + @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( @@ -307,6 +418,7 @@ class Fabric262ArtifactTest { } private companion object { + const val MAX_ARTIFACT_BYTES = 63L * 1024L * 1024L val FORBIDDEN_TYPE_PREFIXES = listOf( "io.libp2p.", "io.netty.", diff --git a/share/forge-1.20.1/build.gradle.kts b/share/forge-1.20.1/build.gradle.kts index ed8606e95..81bf3bc5b 100644 --- a/share/forge-1.20.1/build.gradle.kts +++ b/share/forge-1.20.1/build.gradle.kts @@ -210,9 +210,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + val limit = 63L * 1024L * 1024L check(bytes <= limit) { - "Connect Share Forge 1.20.1 exceeds the 90 MiB release budget ($bytes bytes)" + "Connect Share Forge 1.20.1 exceeds the 63 MiB release budget ($bytes bytes)" } } } diff --git a/share/neoforge-1.21.1/build.gradle.kts b/share/neoforge-1.21.1/build.gradle.kts index dd8ac965f..1d5290107 100644 --- a/share/neoforge-1.21.1/build.gradle.kts +++ b/share/neoforge-1.21.1/build.gradle.kts @@ -172,9 +172,9 @@ val verifyConnectShareArtifactSize = tasks.register("verifyConnectShareArtifactS inputs.file(artifact) doLast { val bytes = artifact.get().asFile.length() - val limit = 90L * 1024L * 1024L + val limit = 63L * 1024L * 1024L check(bytes <= limit) { - "Connect Share NeoForge 1.21.1 exceeds the 90 MiB release budget ($bytes bytes)" + "Connect Share NeoForge 1.21.1 exceeds the 63 MiB release budget ($bytes bytes)" } } } From d4cfeae4437e84c55b4c9c1756dd93d53fee9588 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:40:19 +0200 Subject: [PATCH 173/188] docs(share): record distribution artifact evidence --- docs/connect-share-adoption-evidence.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 3c1eb4096..3710d76d8 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -24,7 +24,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. - Current source head for product probes: - `73f306ff84fbf0e8d24426945e6cfd813cc14301`. + `fc496fda3fe1d973d1a2b4df73cc8b34745b5767`. - 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`. @@ -60,6 +60,14 @@ Status meanings: 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. ## #95 — one-click presence, request, approval, and join @@ -86,6 +94,18 @@ Status meanings: | 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 | Deterministic proof | Fabric 1.20.1/1.21.1/1.21.11/26.2, Forge 1.20.1, and NeoForge 1.21.1 all built and passed packaged artifact tests | Real-client startup and join evidence remains required for every release target | +| Publish verified artifacts on Modrinth, CurseForge, and GitHub Releases | Gap | Artifacts have unambiguous loader/version archive names and deterministic local verification | Marketplace projects, credentials, signing/release workflow, public metadata, 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 package KotlinForForge in their distributable 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 | + ## #99 — let friends join without installing the mod | Acceptance criterion | Status | Evidence | Remaining proof | From e9d13e04e2cc613195ddec488cafb6cdc587213a Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 00:55:53 +0200 Subject: [PATCH 174/188] test(share): record clean-head friend join proof --- docs/connect-share-adoption-evidence.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 3710d76d8..f01e0ec49 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -24,7 +24,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. - Current source head for product probes: - `fc496fda3fe1d973d1a2b4df73cc8b34745b5767`. + `81ac77b0244db0e6b29abc97559f641f2e935710`. - 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`. @@ -68,6 +68,14 @@ Status meanings: 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. ## #95 — one-click presence, request, approval, and join From 419c0d878042e320191776d1e47641832da5a643 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 01:05:50 +0200 Subject: [PATCH 175/188] docs(share): define rollout and operations gates --- README.md | 10 ++ docs/connect-share-adoption-evidence.md | 47 +++++++- docs/connect-share-handoff.md | 65 ++++++++++ docs/connect-share-known-issues.md | 19 +++ docs/connect-share-launch.md | 80 +++++++++++++ docs/connect-share-marketplace-kit.md | 80 +++++++++++++ docs/connect-share-operations.md | 113 ++++++++++++++++++ docs/connect-share-threat-model.md | 53 ++++++++ docs/connect-share.md | 9 ++ ...-08-03-connect-share-release-operations.md | 54 +++++++++ 10 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 docs/connect-share-handoff.md create mode 100644 docs/connect-share-known-issues.md create mode 100644 docs/connect-share-launch.md create mode 100644 docs/connect-share-marketplace-kit.md create mode 100644 docs/connect-share-operations.md create mode 100644 docs/connect-share-threat-model.md create mode 100644 docs/plans/2026-08-03-connect-share-release-operations.md diff --git a/README.md b/README.md index 8179ff47a..e853158fd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,16 @@ See [the player, privacy, installation, and distribution guide](docs/connect-sha for the supported versions, required dependencies, player flow, and release details. +Release and adoption work is governed by the +[fallback operations](docs/connect-share-operations.md), +[threat model](docs/connect-share-threat-model.md), +[staged launch](docs/connect-share-launch.md), and +[HTTPS handoff](docs/connect-share-handoff.md) contracts. Those documents +separate repository evidence from external deployment and review gates. +Marketplace and support teams use the +[creator source kit](docs/connect-share-marketplace-kit.md) and +[known-issues page](docs/connect-share-known-issues.md). + The mod artifacts have their own build and acceptance process. They are not part of the stable proxy/plugin release workflow. See [docs/connect-share-testing.md](docs/connect-share-testing.md) for the manual diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index f01e0ec49..f01626c30 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -108,12 +108,24 @@ Status meanings: |---|---|---|---| | 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 | Deterministic proof | Fabric 1.20.1/1.21.1/1.21.11/26.2, Forge 1.20.1, and NeoForge 1.21.1 all built and passed packaged artifact tests | Real-client startup and join evidence remains required for every release target | -| Publish verified artifacts on Modrinth, CurseForge, and GitHub Releases | Gap | Artifacts have unambiguous loader/version archive names and deterministic local verification | Marketplace projects, credentials, signing/release workflow, public metadata, and final publication are external release operations and have not occurred from this unmerged PR | +| 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 package KotlinForForge in their distributable 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 | Deterministic proof | `TransportSelectorTest` covers LAN → internet → Connect ordering, mutual internet consent, one fallback attempt, and no-route failure | Force a direct failure and complete a real Connect fallback join after the external session-proposal boundary works | +| 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 | Gap | Real libp2p proxy E2E and clean-head offline Prism direct join pass; deterministic selector/auth/network refresh cases pass | Real Connect fallback, paid online-auth join, and network-change automation remain blocked by service/matrix environments | +| Real-client startup/join gate for every supported release target | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | + ## #99 — let friends join without installing the mod | Acceptance criterion | Status | Evidence | Remaining proof | @@ -151,6 +163,39 @@ Status meanings: | 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 | 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..c56274009 --- /dev/null +++ b/docs/connect-share-known-issues.md @@ -0,0 +1,19 @@ +# 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 | +|---|---|---| +| No-mod fallback | A vanilla client reaches the public Connect edge, but the tested edge did not deliver a session proposal to the local host, so admission and gameplay did not begin | Use two modded clients on the proven direct path; service owners must resolve and prove the edge/session boundary before advertising vanilla joining | +| 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 | Clean packaged direct-join product proof exists for Fabric 26.2 on macOS arm64; the remaining loader/version/OS/architecture matrix is deterministic only | Treat other artifacts as prerelease until their real-client startup and join 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..4bcd1c9be --- /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..a31a0019f --- /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-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 index 2d6fbc2d6..d5276a69e 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -132,6 +132,15 @@ 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 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..79a3c2fd0 --- /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. +- [ ] Run Markdown/link checks available in the repository, workflow syntax + checks, targeted tests, the broader build, and `git diff --check`. +- [ ] Push reviewed commits to PR #94; comment on each issue with completed + repository work and exact external gates. Keep the PR unmerged. From 9671e3de9536697a8d43c14c344347ed1217a5e8 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 01:05:50 +0200 Subject: [PATCH 176/188] ci(share): attest release artifacts --- .github/workflows/connect-share-release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/connect-share-release.yml b/.github/workflows/connect-share-release.yml index 9a6e7cc91..6bff82612 100644 --- a/.github/workflows/connect-share-release.yml +++ b/.github/workflows/connect-share-release.yml @@ -16,6 +16,9 @@ on: permissions: contents: write + id-token: write + attestations: write + artifact-metadata: write concurrency: group: connect-share-release-${{ inputs.release_tag }} @@ -85,6 +88,13 @@ jobs: 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 }} @@ -197,4 +207,5 @@ jobs: --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 From 9f899b5b4a758abb8305bb3c04cd681f7fe27f02 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 01:15:58 +0200 Subject: [PATCH 177/188] docs(share): close repository operations checklist --- docs/plans/2026-08-03-connect-share-release-operations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-03-connect-share-release-operations.md b/docs/plans/2026-08-03-connect-share-release-operations.md index 79a3c2fd0..36bdaf6f5 100644 --- a/docs/plans/2026-08-03-connect-share-release-operations.md +++ b/docs/plans/2026-08-03-connect-share-release-operations.md @@ -48,7 +48,7 @@ external dependency to an owner, verification artifact, and issue criterion. ## Task 5: Evidence and handoff - [x] Extend the acceptance matrix for #117, #118, and #119. -- [ ] Run Markdown/link checks available in the repository, workflow syntax +- [x] Run Markdown/link checks available in the repository, workflow syntax checks, targeted tests, the broader build, and `git diff --check`. -- [ ] Push reviewed commits to PR #94; comment on each issue with completed +- [x] Push reviewed commits to PR #94; comment on each issue with completed repository work and exact external gates. Keep the PR unmerged. From d1d6f5e28a199708f58e35db06d36baa9f69f553 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 10:11:13 +0200 Subject: [PATCH 178/188] fix(share): surface safe admission denials --- .../com/minekube/connect/register/WatcherRegister.java | 9 ++++++++- .../minekube/connect/register/WatcherRegisterTest.java | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 9c98ea732..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; @@ -438,9 +440,14 @@ private void complete( return; } if (!decision.isAllowed() && !decision.isDeferredToLocalLogin()) { + String safeMessage = decision.getSafeMessage(); reject(proposal, Status.newBuilder() .setCode(Code.PERMISSION_DENIED_VALUE) - .setMessage(decision.getSafeMessage()) + .setMessage(safeMessage) + .addDetails(Any.pack(LocalizedMessage.newBuilder() + .setLocale("en-US") + .setMessage(safeMessage) + .build())) .build()); return; } 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 bd3ff9acb..2b7f0806e 100644 --- a/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java +++ b/core/src/test/java/com/minekube/connect/register/WatcherRegisterTest.java @@ -22,6 +22,7 @@ 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; @@ -487,6 +488,11 @@ void deniedOrTimedOutAdmissionRejectsWithoutTunnelWork() throws Exception { 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); } From 756b3ea57c8bf0f2a21fa311c000c1d344a417ec Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 10:45:05 +0200 Subject: [PATCH 179/188] fix(share): harden join authorization grants --- share/AGENTS.md | 17 +++- .../share/admission/AdmissionController.kt | 26 ++++-- .../admission/AdmissionControllerTest.kt | 56 ++++++++++++- .../fabric/v1_20_1/FriendCardNetworking.kt | 1 + .../fabric/v1_21_1/FriendCardNetworking.kt | 1 + .../fabric/v1_21_11/FriendCardNetworking.kt | 1 + .../fabric/v26_2/FriendCardNetworking.kt | 1 + .../share/fabric/ApprovedJoinTracker.kt | 20 ++++- .../share/fabric/ApprovedJoinTrackerTest.kt | 83 ++++++++++++++++--- .../fabric/FabricSessionAdmissionGateTest.kt | 13 +-- .../v1_20_1/ForgeFriendCardNetworking.kt | 1 + .../v1_21_1/NeoForgeFriendCardNetworking.kt | 1 + 12 files changed, 190 insertions(+), 31 deletions(-) diff --git a/share/AGENTS.md b/share/AGENTS.md index 429ad2e9e..6dac06c9b 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -122,7 +122,22 @@ redesigned for Kotlin. - Connect's no-mod session admission must finish before vanilla's own connection timeout. Preserve a deadline buffer, cancel the pending host request when it expires, and test the guest-visible actionable denial; - generic `Timed out` is a failed UX result. + generic `Timed out` is a failed UX result. Encode an intentional denial as + `PermissionDenied` with the safe copy repeated in a + `google.rpc.LocalizedMessage` detail: Moxy intentionally never shows a + connector-controlled raw status message. A bounded `PermissionDenied` + response proves the proposal reached this connector, so diagnose host + admission rather than session delivery. +- An approved gameplay join may enable automatic friend-card exchange only + when its proof carries a direct peer ID and the subsequently supplied, + signature-verified invitation names that same peer. Name and Minecraft UUID + are not sufficient for offline or Connect-only sessions; fail closed rather + than turning an unbound admission into `AUTO_ACCEPT` friendship. +- `approveNextJoin` grants are one-shot admission capabilities, not durable + friend state. Expire them within the admission timeout, deduplicate them, + and bound the queue by `maxPending`; when full, evict the oldest grant so a + requester cannot accumulate arbitrary UUID grants or grow memory without + bound. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, then follow [the testing guide](../docs/connect-share-testing.md) for the complete two-client launch and evidence gates. Keep machine-specific paths 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 index e8db30569..7cc6a8f5a 100644 --- 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 @@ -20,11 +20,12 @@ class AdmissionController( 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 = mutableSetOf() + private val preapprovedJoins = linkedMapOf() private val mutablePending = MutableStateFlow>(emptyList()) val pending: StateFlow> = mutablePending.asStateFlow() @@ -51,7 +52,8 @@ class AdmissionController( return@synchronized RequestLookup.Immediate(AdmissionAnswer.CAPACITY) } if (purpose == AdmissionPurpose.JOIN) { - val preapproved = preapprovedJoins.firstOrNull { + removeExpiredPreapprovals(nowMillis()) + val preapproved = preapprovedJoins.keys.firstOrNull { it.matches(identity) } if (preapproved != null) { @@ -137,7 +139,7 @@ class AdmissionController( purpose: AdmissionPurpose, ): Int { val denied = synchronized(lock) { - preapprovedJoins.removeIf { it.directPeerId == peerId } + preapprovedJoins.keys.removeIf { it.directPeerId == peerId } val matches = requests.entries.filter { entry -> entry.value.pending.purpose == purpose && entry.value.pending.identity.directPeerId == peerId @@ -155,7 +157,7 @@ class AdmissionController( minecraftUuid: UUID? = null, ): Int { val revoked = synchronized(lock) { - preapprovedJoins.removeIf { it.directPeerId == peerId } + preapprovedJoins.keys.removeIf { it.directPeerId == peerId } authenticatedApprovals.removeIf { it.directPeerId == peerId || ( @@ -197,10 +199,24 @@ class AdmissionController( fun approveNextJoin(identity: AdmissionIdentity) { synchronized(lock) { - preapprovedJoins += PreapprovedJoin( + 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 } } 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 index 9a5061413..25d018cb8 100644 --- 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 @@ -326,6 +326,57 @@ class AdmissionControllerTest { ) } + @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() @@ -381,13 +432,16 @@ class AdmissionControllerTest { connectedCount: () -> Int = { 0 }, maxGuests: () -> Int = { 8 }, autoApprove: (AdmissionIdentity) -> Boolean = { false }, + maxPending: Int = 16, + nowMillis: () -> Long = System::currentTimeMillis, ) = AdmissionController( scope = backgroundScope, timeout = 30.seconds, - maxPending = 16, + maxPending = maxPending, connectedCount = connectedCount, maxGuests = maxGuests, autoApprove = autoApprove, + nowMillis = nowMillis, ) private fun authenticated( 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 index 46ac46f6f..1e534b4f8 100644 --- 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 @@ -29,6 +29,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name, player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index 2387e836e..e3a3ebff2 100644 --- 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 @@ -35,6 +35,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name, player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index ddc97a07d..566857c3a 100644 --- 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 @@ -35,6 +35,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name(), player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index db7a3de3c..707ca5080 100644 --- 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 @@ -35,6 +35,7 @@ object FriendCardNetworking { val proof = approvedJoins.consume( player.gameProfile.name(), player.uuid, + payload.invitation, ) ?: return@execute receiver.receive( invitation = payload.invitation, 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 index fd490811f..b8d865533 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -52,20 +54,30 @@ class ApprovedJoinTracker( approved.remove(key, timedProof) return false } - return true + return timedProof.directPeerId != null } fun consume( name: String, uuid: UUID, + invitation: String, ): ApprovedJoinProof? { val timedProof = approved.remove( PlayerKey(name.normalized(), uuid), ) ?: return null - return timedProof.proof.takeIf { - nowMillis() - timedProof.approvedAtMillis <= - PROOF_LIFETIME_MILLIS + 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( 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 index 3cf1ee680..0e8bc6a3d 100644 --- 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 @@ -4,34 +4,49 @@ 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`() { - tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + 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) + tracker.consume("Robin", PLAYER_UUID, invitation) ?.authenticatedMinecraftUuid, ) - assertNull(tracker.consume("Robin", PLAYER_UUID)) + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) } @Test - fun `approved offline identity proves pairing without trusting its uuid`() { - tracker.record(OFFLINE, AdmissionAnswer.ALLOW) + 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) + val proof = tracker.consume("Robin", PLAYER_UUID, invitation) assertNotNull(proof) assertNull(proof.authenticatedMinecraftUuid) @@ -42,15 +57,19 @@ class ApprovedJoinTrackerTest { tracker.record(AUTHENTICATED, AdmissionAnswer.DENY) assertEquals(false, tracker.hasProof("Robin", PLAYER_UUID)) - assertNull(tracker.consume("Robin", PLAYER_UUID)) + assertNull(tracker.consume("Robin", PLAYER_UUID, "invalid")) } @Test - fun `authentication proof expires before an unrelated later join`() { - tracker.record(AUTHENTICATED, AdmissionAnswer.ALLOW) + 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)) + assertNull(tracker.consume("Robin", PLAYER_UUID, invitation)) } @Test @@ -70,6 +89,48 @@ class ApprovedJoinTrackerTest { 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") 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 index be1defab6..fbd95eb0c 100644 --- 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 @@ -102,10 +102,9 @@ class FabricSessionAdmissionGateTest { admission.answer(pending.requestId, allow = true) runCurrent() assertTrue(result.getNow(null).isAllowed) - assertEquals( - PLAYER_UUID, - approvedJoins.consume("Alex", PLAYER_UUID) - ?.authenticatedMinecraftUuid, + assertFalse( + approvedJoins.hasProof("Alex", PLAYER_UUID), + "a Connect-only identity cannot authorize automatic friendship", ) } @@ -235,11 +234,7 @@ class FabricSessionAdmissionGateTest { ) admission.answer(admission.pending.value.single().requestId, allow = true) assertEquals(AdmissionAnswer.ALLOW, authenticated.await()) - assertEquals( - PLAYER_UUID, - approvedJoins.consume("Alex", PLAYER_UUID) - ?.authenticatedMinecraftUuid, - ) + assertTrue(approvedJoins.hasProof("Alex", PLAYER_UUID)) val offline = async { local.request( 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 index 40c78ded2..62c5d6014 100644 --- 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 @@ -66,6 +66,7 @@ object ForgeFriendCardNetworking { val proof = handlers.approvedJoins.consume( player.gameProfile.name, player.uuid, + message.invitation, ) ?: return@consumerMainThread handlers.scope.launch(Dispatchers.IO) { handlers.receiver.receive( 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 index 0ede63d1e..ced6c04b9 100644 --- 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 @@ -60,6 +60,7 @@ object NeoForgeFriendCardNetworking { val proof = handlers.approvedJoins.consume( player.gameProfile.name, player.uuid, + payload.invitation, ) ?: return@playToServer handlers.scope.launch(Dispatchers.IO) { handlers.receiver.receive( From a4d607e257499702456862d17f467339db9da5aa Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 10:54:31 +0200 Subject: [PATCH 180/188] test(share): drive a real Connect fallback join --- share/AGENTS.md | 9 +++- .../share/fabric/PrismFriendJoinE2ETest.kt | 48 +++++++++++++------ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/share/AGENTS.md b/share/AGENTS.md index 6dac06c9b..011af97ac 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -141,8 +141,13 @@ redesigned for Kotlin. - `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, then follow [the testing guide](../docs/connect-share-testing.md) for the complete two-client launch and evidence gates. Keep machine-specific paths - in `LIVE_DATA`, `LIVE_PORT_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` - environment variables. + in `LIVE_DATA`, `LIVE_TARGET_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` + environment variables (`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. - Invoke the live harness with `--rerun-tasks`. Its environment variables are intentionally not task inputs, so an up-to-date result is not live evidence. - Keep only one host and one guest identity active during a live run. Cloning a 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 index 9520fa912..b22f6c306 100644 --- 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 @@ -13,7 +13,6 @@ import java.nio.file.attribute.BasicFileAttributes import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.test.fail import kotlinx.coroutines.delay @@ -83,17 +82,20 @@ class PrismFriendJoinE2ETest { fun `saved friend requests and joins a live singleplayer world`() = runBlocking { val dataValue = System.getenv("LIVE_DATA") - val portValue = System.getenv("LIVE_PORT_FILE") + val targetValue = System.getenv("LIVE_TARGET_FILE") + ?: System.getenv("LIVE_PORT_FILE") val hostLogValue = System.getenv("LIVE_HOST_LOG") assumeTrue( - dataValue != null && portValue != null && hostLogValue != null, - "LIVE_DATA, LIVE_PORT_FILE, and LIVE_HOST_LOG enable this E2E", + 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 portFile = Path.of(checkNotNull(portValue)) + 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() @@ -179,17 +181,26 @@ class PrismFriendJoinE2ETest { ).getOrNull() }, ) - val gameplay = assertIs( - browser.join( - friend, - DirectP2pAuthMode.OFFLINE, - ).getOrNull(), - ) + if (forceConnectFallback) { + forceDirectFailure(browser) + } + val gameplay = browser.join( + friend, + DirectP2pAuthMode.OFFLINE, + ).getOrNull() ?: fail("No gameplay route was available") gameplay.use { - Files.writeString( - portFile, - gameplay.localAddress.port.toString(), - ) + 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() @@ -216,6 +227,13 @@ class PrismFriendJoinE2ETest { } } + 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, From ee31203d85a5afcb573521947ee2e800ac01ad50 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 11:06:29 +0200 Subject: [PATCH 181/188] docs(share): record fallback and admission evidence --- docs/connect-share-adoption-evidence.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index f01626c30..4de9f2762 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -118,32 +118,32 @@ Status meanings: | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| -| Direct first and exactly one automatic Connect fallback | Deterministic proof | `TransportSelectorTest` covers LAN → internet → Connect ordering, mutual internet consent, one fallback attempt, and no-route failure | Force a direct failure and complete a real Connect fallback join after the external session-proposal boundary works | +| 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 | Gap | Real libp2p proxy E2E and clean-head offline Prism direct join pass; deterministic selector/auth/network refresh cases pass | Real Connect fallback, paid online-auth join, and network-change automation remain blocked by service/matrix environments | +| Automated two-client direct, fallback, offline, online, and network-change cases | Partial product proof | Real libp2p direct and forced Connect fallback Prism 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 | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | ## #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 reached Connecting through the ordinary public address | Inspect the copy action, then resolve the external Connect forwarding boundary and complete a vanilla join | +| 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 reached the active connector through the ordinary public address and received the bounded host-admission rejection | Inspect the copy action and record one human-approved vanilla join; Minecraft UI approval is intentionally not automated | | 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | | 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 required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; the first product probe reproduced generic `Timed out` and drove the fix | The Connect edge must deliver a session before the rebuilt denial can be observed on vanilla | +| Errors distinguish unavailable host from invalid or expired admission | Product proof required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; a live no-mod probe returned connector `PermissionDenied`, proving delivery, and the connector now sends safe copy in `google.rpc.LocalizedMessage` | Moxy PR #512 must be merged and deployed through its guarded rollout before the rebuilt terminal denial can be observed on vanilla | ## #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` (`offline reconnect with copied name requires a new approval`) | 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 | @@ -210,9 +210,11 @@ Status meanings: ## Open foundation gaps The baseline intentionally leaves #95, #96, #99, #100, and #103 open until the -remaining exact-head product claims are observed. The deterministic gaps found -in the first audit are fixed in `9397658c`; the direct Prism join is proven and -the no-mod attempt is now blocked specifically at external Connect session -forwarding. Minecraft UI clicks are never automated; any irreducible approval -interaction is recorded as a human checkpoint with all other evidence gathered -noninteractively. +remaining exact-head product claims are observed. The first audit fixes remain, +the direct and forced Connect-fallback 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 probe now proves Connect +session delivery and host admission; only the explicit human acceptance pass +and the unmerged Moxy terminal-denial rollout remain. Minecraft UI clicks are +never automated, so that irreducible approval interaction is recorded as a +human checkpoint while all other evidence is gathered noninteractively. From 78b9450d63a94b49f6059aeea8adc5d853d48cb4 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 16:49:24 +0200 Subject: [PATCH 182/188] docs(share): record vanilla join proof --- .../skills/connect-share-prism-e2e/SKILL.md | 16 +++++++++ docs/connect-share-adoption-evidence.md | 36 +++++++++++++------ share/AGENTS.md | 13 +++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index be34cb0b6..4934a5c9d 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -64,6 +64,12 @@ 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 @@ -136,6 +142,16 @@ 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. diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 4de9f2762..a3e056bda 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -24,7 +24,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. - Current source head for product probes: - `81ac77b0244db0e6b29abc97559f641f2e935710`. + `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`. @@ -52,6 +52,19 @@ Status meanings: `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. - 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 @@ -123,18 +136,18 @@ Status meanings: | 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 and forced Connect fallback Prism 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 | +| 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 | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | ## #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 reached the active connector through the ordinary public address and received the bounded host-admission rejection | Inspect the copy action and record one human-approved vanilla join; Minecraft UI approval is intentionally not automated | +| 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; `ShareCoordinatorTest` validates the guest range | Record approval, denial/timeout, and capacity behavior for a vanilla guest without automating Minecraft clicks | +| 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 required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; a live no-mod probe returned connector `PermissionDenied`, proving delivery, and the connector now sends safe copy in `google.rpc.LocalizedMessage` | Moxy PR #512 must be merged and deployed through its guarded rollout before the rebuilt terminal denial can be observed on vanilla | @@ -211,10 +224,11 @@ Status meanings: 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 and forced Connect-fallback 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 probe now proves Connect -session delivery and host admission; only the explicit human acceptance pass -and the unmerged Moxy terminal-denial rollout remain. Minecraft UI clicks are -never automated, so that irreducible approval interaction is recorded as a -human checkpoint while all other evidence is gathered noninteractively. +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 remains intentionally unmerged and its +terminal-denial behavior therefore remains undeployed; production must not be +called fixed for that rejection UX until the guarded Moxy rollout and live +denial smoke test are complete. diff --git a/share/AGENTS.md b/share/AGENTS.md index 011af97ac..e4dd7eeb2 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -73,6 +73,11 @@ redesigned for Kotlin. `prismlauncher --launch --offline --server `. `--offline ` is authoritative; editing `InstanceAccountId` while Prism runs is not, because Prism rewrites it. +- A matching Prism JVM PID does not prove a fresh launch. Snapshot + `minecraft/logs/latest.log` before launch, require a newer mtime plus the + expected world/runtime markers, and treat an old JVM with an unchanged log as + an occupied stale instance. Before terminating one, resolve exactly one PID + by its instance working directory; never kill a broad Java process set. - Prove the flow in layers: mDNS discovery, authenticated friend activity, Minecraft status when host privacy permits it, then follow [the testing guide](../docs/connect-share-testing.md) for the real two-client login @@ -119,6 +124,14 @@ redesigned for Kotlin. the confirmed test friend, send the real libp2p join request, and restore the permission afterwards. Keep machine-specific instance paths and credentials in environment variables, never in committed tests or scripts. +- A vanilla no-mod Connect join has no signed direct-peer proof. Its Connect + profile may therefore require an ordinary pending admission even when a + same-named offline friend is set to auto-accept; do not weaken UUID/peer + matching to make a test pass. An unattended local proof may attach a + temporary, uncommitted driver that resolves the existing `ShareViewModel`, + asserts exactly one pending admission, and invokes its normal `allow` action. + Emit only stage/result booleans, remove the driver afterwards, and never ship + a production bypass or log the pending identity/request ID. - Connect's no-mod session admission must finish before vanilla's own connection timeout. Preserve a deadline buffer, cancel the pending host request when it expires, and test the guest-visible actionable denial; From a553ec3b7a0283563b9407c42d8240a393c4b82f Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 22:44:44 +0200 Subject: [PATCH 183/188] fix(share): isolate friend forms and safe menu focus --- .../skills/connect-share-prism-e2e/SKILL.md | 6 ++ docs/connect-share-adoption-evidence.md | 24 ++++++-- docs/connect-share-known-issues.md | 3 +- share/AGENTS.md | 7 +++ .../v1_20_1/mixin/PauseScreenMixin.java | 2 + .../share/fabric/v1_20_1/ShareJoinScreen.kt | 58 +++++++++++++++---- .../v1_21_1/mixin/PauseScreenMixin.java | 2 + .../share/fabric/v1_21_1/ShareJoinScreen.kt | 58 +++++++++++++++---- .../v1_21_11/mixin/PauseScreenMixin.java | 2 + .../share/fabric/v1_21_11/ShareJoinScreen.kt | 58 +++++++++++++++---- .../fabric/v26_2/mixin/PauseScreenMixin.java | 2 + .../share/fabric/v26_2/ShareJoinScreen.kt | 58 +++++++++++++++---- .../fabric/v26_2/Fabric262ArtifactTest.kt | 15 +++++ .../share/fabric/ui/AdaptiveShareLayout.kt | 23 +++++++- .../share/fabric/ui/FriendFormDraft.kt | 15 +++++ .../fabric/ui/AdaptiveShareLayoutTest.kt | 17 ++++++ .../share/fabric/ui/FriendFormDraftTest.kt | 30 ++++++++++ 17 files changed, 326 insertions(+), 54 deletions(-) create mode 100644 share/fabric-common/src/main/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraft.kt create mode 100644 share/fabric-common/src/test/kotlin/com/minekube/connect/share/fabric/ui/FriendFormDraftTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 4934a5c9d..3feda93dc 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -132,6 +132,12 @@ Treat visual QA as a keyboard-only Prism test, not as a source review: 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 diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index a3e056bda..8d5ce3ff0 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -23,7 +23,7 @@ Status meanings: - Original acceptance-audit commit: `6073f2f6101d86d38c71e517148725fd2c089c82`. -- Current source head for product probes: +- 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. @@ -65,6 +65,18 @@ Status meanings: 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 @@ -149,7 +161,7 @@ Status meanings: | 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 required | `RemoteLoginMessage` and `FabricSessionAdmissionGateTest` provide distinct text and reserve ten seconds before vanilla's timeout; a live no-mod probe returned connector `PermissionDenied`, proving delivery, and the connector now sends safe copy in `google.rpc.LocalizedMessage` | Moxy PR #512 must be merged and deployed through its guarded rollout before the rebuilt terminal denial can be observed on vanilla | +| 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 @@ -228,7 +240,7 @@ 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 remains intentionally unmerged and its -terminal-denial behavior therefore remains undeployed; production must not be -called fixed for that rejection UX until the guarded Moxy rollout and live -denial smoke test are complete. +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-known-issues.md b/docs/connect-share-known-issues.md index c56274009..65bf40be1 100644 --- a/docs/connect-share-known-issues.md +++ b/docs/connect-share-known-issues.md @@ -6,11 +6,10 @@ item is resolved and its evidence is linked. | Area | Current limitation | Safe action | |---|---|---| -| No-mod fallback | A vanilla client reaches the public Connect edge, but the tested edge did not deliver a session proposal to the local host, so admission and gameplay did not begin | Use two modded clients on the proven direct path; service owners must resolve and prove the edge/session boundary before advertising vanilla joining | | 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 | Clean packaged direct-join product proof exists for Fabric 26.2 on macOS arm64; the remaining loader/version/OS/architecture matrix is deterministic only | Treat other artifacts as prerelease until their real-client startup and join gates pass | +| Platform matrix | Clean packaged direct, Connect fallback, no-mod approval, and no-mod terminal-denial product proof exists for Fabric 26.2 on macOS arm64; the remaining loader/version/OS/architecture matrix is deterministic only | Treat other artifacts as prerelease until their real-client startup and join 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 diff --git a/share/AGENTS.md b/share/AGENTS.md index e4dd7eeb2..a7a3c6cd5 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -187,6 +187,13 @@ redesigned for Kotlin. persistent label; split pause-menu buttons must keep copy within their 100-pixel logical width. The repository Prism skill owns the capture and focus-order procedure. +- Treat add-link and manage-friend values as separate form sessions. Entering + Add from the Friends list, leaving Manage, or completing/removing/blocking a + relationship must clear name, invitation, offline-mode, and internet-direct + draft state; only the Add → Connection options → Add round trip preserves it. + On the pause screen, visual placement does not change keyboard order: remove + and re-add the vanilla disconnect button so Share and Friends are focused + before the destructive exit action. - Recovery export/import must run only against the fixed Share allowlist and while sharing is stopped. A selected backup target must never resolve to a live identity, friend, preference, endpoint, or transaction path. Validate 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 index fb8bc3091..ca29678a5 100644 --- 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 @@ -37,6 +37,7 @@ protected PauseScreenMixin(Component title) { int rowX = disconnectButton.getX(); int rowY = disconnectButton.getY(); disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); if (client.hasSingleplayerServer()) { connectShareButton = addRenderableWidget( Button.builder( @@ -59,6 +60,7 @@ protected PauseScreenMixin(Component title) { .bounds(rowX, rowY, 204, 20) .build()); } + addRenderableWidget(disconnectButton); } @Inject(method = "tick", at = @At("TAIL")) 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 index 54c8110ae..9bcf25bf7 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -142,11 +143,13 @@ class ShareJoinScreen( Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = if (mode == Mode.CONNECTION_OPTIONS) { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { Mode.ADD } else { Mode.FRIENDS } + if (!returningToAdd) resetFriendFormDraft() selectedPeerId = null safeMessage = null rebuildWidgets() @@ -296,6 +299,7 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.add"), ) { + resetFriendFormDraft() mode = Mode.ADD safeMessage = null rebuildWidgets() @@ -491,7 +495,9 @@ class ShareJoinScreen( Component.literal("…"), ) { selectedPeerId = friend.peerId - nameValue = friend.displayName + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) mode = Mode.MANAGE safeMessage = null rebuildWidgets() @@ -741,6 +747,7 @@ class ShareJoinScreen( return } val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( @@ -762,11 +769,21 @@ class ShareJoinScreen( layout.contentWidth, ), ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) nameBox = addRenderableWidget( EditBox( font, layout.contentX, - layout.bodyTop, + form.nameInputY, layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), @@ -782,7 +799,7 @@ class ShareJoinScreen( val notify = addRenderableWidget( ObservableCheckbox( layout.contentX, - layout.bodyTop + 28, + form.notifyY, layout.contentWidth, 20, Component.translatable("connect_share.friends.notify"), @@ -801,7 +818,7 @@ class ShareJoinScreen( .withValues(FriendAccessPolicy.entries) .create( layout.contentX, - layout.bodyTop + 74, + form.accessPolicyY, layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), @@ -810,7 +827,7 @@ class ShareJoinScreen( val shareWorlds = addRenderableWidget( ObservableCheckbox( layout.contentX, - layout.bodyTop + 50, + form.shareWorldsY, layout.contentWidth, 20, Component.translatable("connect_share.friends.share_worlds"), @@ -821,7 +838,7 @@ class ShareJoinScreen( CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) .create( layout.contentX, - layout.bodyTop + 100, + form.internetDirectY, layout.contentWidth, 20, Component.translatable( @@ -864,6 +881,7 @@ class ShareJoinScreen( requestOperationInProgress = false mode = Mode.FRIENDS selectedPeerId = null + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -936,7 +954,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -961,7 +979,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -1097,8 +1115,7 @@ class ShareJoinScreen( return@launch } mode = Mode.FRIENDS - invitationValue = "" - nameValue = "" + resetFriendFormDraft() rebuildWidgets() deliverOutgoing(peerId) } @@ -1326,6 +1343,25 @@ class ShareJoinScreen( 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( 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 index 54cfe3580..2395bb925 100644 --- 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 @@ -37,6 +37,7 @@ protected PauseScreenMixin(Component title) { int rowX = disconnectButton.getX(); int rowY = disconnectButton.getY(); disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); if (client.hasSingleplayerServer()) { connectShareButton = addRenderableWidget( Button.builder( @@ -59,6 +60,7 @@ protected PauseScreenMixin(Component title) { .bounds(rowX, rowY, 204, 20) .build()); } + addRenderableWidget(disconnectButton); } @Inject(method = "tick", at = @At("TAIL")) 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 index 83fca4931..f18302189 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -142,11 +143,13 @@ class ShareJoinScreen( Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = if (mode == Mode.CONNECTION_OPTIONS) { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { Mode.ADD } else { Mode.FRIENDS } + if (!returningToAdd) resetFriendFormDraft() selectedPeerId = null safeMessage = null rebuildWidgets() @@ -296,6 +299,7 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.add"), ) { + resetFriendFormDraft() mode = Mode.ADD safeMessage = null rebuildWidgets() @@ -491,7 +495,9 @@ class ShareJoinScreen( Component.literal("…"), ) { selectedPeerId = friend.peerId - nameValue = friend.displayName + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) mode = Mode.MANAGE safeMessage = null rebuildWidgets() @@ -739,6 +745,7 @@ class ShareJoinScreen( return } val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( @@ -760,11 +767,21 @@ class ShareJoinScreen( layout.contentWidth, ), ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) nameBox = addRenderableWidget( EditBox( font, layout.contentX, - layout.bodyTop, + form.nameInputY, layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), @@ -781,7 +798,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(layout.contentX, layout.bodyTop + 28) + ).pos(layout.contentX, form.notifyY) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -797,7 +814,7 @@ class ShareJoinScreen( .withValues(FriendAccessPolicy.entries) .create( layout.contentX, - layout.bodyTop + 74, + form.accessPolicyY, layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), @@ -807,7 +824,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(layout.contentX, layout.bodyTop + 50) + ).pos(layout.contentX, form.shareWorldsY) .selected(friend.permissions.canSeeMyWorlds) .build(), ) @@ -815,7 +832,7 @@ class ShareJoinScreen( CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) .create( layout.contentX, - layout.bodyTop + 100, + form.internetDirectY, layout.contentWidth, 20, Component.translatable( @@ -858,6 +875,7 @@ class ShareJoinScreen( requestOperationInProgress = false mode = Mode.FRIENDS selectedPeerId = null + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -930,7 +948,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -955,7 +973,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -1091,8 +1109,7 @@ class ShareJoinScreen( return@launch } mode = Mode.FRIENDS - invitationValue = "" - nameValue = "" + resetFriendFormDraft() rebuildWidgets() deliverOutgoing(peerId) } @@ -1320,6 +1337,25 @@ class ShareJoinScreen( 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( 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 index 4aaa1fa4c..24cca4833 100644 --- 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 @@ -37,6 +37,7 @@ protected PauseScreenMixin(Component title) { int rowX = disconnectButton.getX(); int rowY = disconnectButton.getY(); disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); if (client.hasSingleplayerServer()) { connectShareButton = addRenderableWidget( Button.builder( @@ -59,6 +60,7 @@ protected PauseScreenMixin(Component title) { .bounds(rowX, rowY, 204, 20) .build()); } + addRenderableWidget(disconnectButton); } @Inject(method = "tick", at = @At("TAIL")) 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 index c1f104488..e45b265aa 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -142,11 +143,13 @@ class ShareJoinScreen( Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = if (mode == Mode.CONNECTION_OPTIONS) { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { Mode.ADD } else { Mode.FRIENDS } + if (!returningToAdd) resetFriendFormDraft() selectedPeerId = null safeMessage = null rebuildWidgets() @@ -296,6 +299,7 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.add"), ) { + resetFriendFormDraft() mode = Mode.ADD safeMessage = null rebuildWidgets() @@ -491,7 +495,9 @@ class ShareJoinScreen( Component.literal("…"), ) { selectedPeerId = friend.peerId - nameValue = friend.displayName + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) mode = Mode.MANAGE safeMessage = null rebuildWidgets() @@ -739,6 +745,7 @@ class ShareJoinScreen( return } val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( @@ -760,11 +767,21 @@ class ShareJoinScreen( layout.contentWidth, ), ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) nameBox = addRenderableWidget( EditBox( font, layout.contentX, - layout.bodyTop, + form.nameInputY, layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), @@ -781,7 +798,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(layout.contentX, layout.bodyTop + 28) + ).pos(layout.contentX, form.notifyY) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -797,7 +814,7 @@ class ShareJoinScreen( ).withValues(FriendAccessPolicy.entries) .create( layout.contentX, - layout.bodyTop + 74, + form.accessPolicyY, layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), @@ -807,7 +824,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(layout.contentX, layout.bodyTop + 50) + ).pos(layout.contentX, form.shareWorldsY) .selected(friend.permissions.canSeeMyWorlds) .build(), ) @@ -815,7 +832,7 @@ class ShareJoinScreen( CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) .create( layout.contentX, - layout.bodyTop + 100, + form.internetDirectY, layout.contentWidth, 20, Component.translatable( @@ -858,6 +875,7 @@ class ShareJoinScreen( requestOperationInProgress = false mode = Mode.FRIENDS selectedPeerId = null + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -930,7 +948,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -955,7 +973,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -1091,8 +1109,7 @@ class ShareJoinScreen( return@launch } mode = Mode.FRIENDS - invitationValue = "" - nameValue = "" + resetFriendFormDraft() rebuildWidgets() deliverOutgoing(peerId) } @@ -1320,6 +1337,25 @@ class ShareJoinScreen( 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( 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 index 32755099b..15de130df 100644 --- 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 @@ -37,6 +37,7 @@ protected PauseScreenMixin(Component title) { int rowX = disconnectButton.getX(); int rowY = disconnectButton.getY(); disconnectButton.setY(rowY + 24); + removeWidget(disconnectButton); if (client.hasSingleplayerServer()) { connectShareButton = addRenderableWidget( Button.builder( @@ -59,6 +60,7 @@ protected PauseScreenMixin(Component title) { .bounds(rowX, rowY, 204, 20) .build()); } + addRenderableWidget(disconnectButton); } @Inject(method = "tick", at = @At("TAIL")) 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 index 6d7ca0d17..9bd32750f 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -142,11 +143,13 @@ class ShareJoinScreen( Mode.CONNECTION_OPTIONS, Mode.MANAGE, -> { - mode = if (mode == Mode.CONNECTION_OPTIONS) { + val returningToAdd = mode == Mode.CONNECTION_OPTIONS + mode = if (returningToAdd) { Mode.ADD } else { Mode.FRIENDS } + if (!returningToAdd) resetFriendFormDraft() selectedPeerId = null safeMessage = null rebuildWidgets() @@ -296,6 +299,7 @@ class ShareJoinScreen( Button.builder( Component.translatable("connect_share.friends.add"), ) { + resetFriendFormDraft() mode = Mode.ADD safeMessage = null rebuildWidgets() @@ -491,7 +495,9 @@ class ShareJoinScreen( Component.literal("…"), ) { selectedPeerId = friend.peerId - nameValue = friend.displayName + applyFriendFormDraft( + FriendFormDraft.forManage(friend.displayName), + ) mode = Mode.MANAGE safeMessage = null rebuildWidgets() @@ -739,6 +745,7 @@ class ShareJoinScreen( return } val layout = AdaptiveShareLayout.form(width, height, 5) + val form = AdaptiveShareLayout.manageFriendForm(layout.bodyTop) var internetDirectSelected = friend.internetDirectGuestOptIn addRenderableWidget( centered( @@ -760,11 +767,21 @@ class ShareJoinScreen( layout.contentWidth, ), ) + addRenderableWidget( + StringWidget( + layout.contentX, + form.nameLabelY, + layout.contentWidth, + 11, + Component.translatable("connect_share.friends.name"), + font, + ), + ) nameBox = addRenderableWidget( EditBox( font, layout.contentX, - layout.bodyTop, + form.nameInputY, layout.contentWidth, 20, Component.translatable("connect_share.friends.name"), @@ -781,7 +798,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.notify"), font, - ).pos(layout.contentX, layout.bodyTop + 28) + ).pos(layout.contentX, form.notifyY) .selected(friend.permissions.notifyWhenOnline) .build(), ) @@ -797,7 +814,7 @@ class ShareJoinScreen( ).withValues(FriendAccessPolicy.entries) .create( layout.contentX, - layout.bodyTop + 74, + form.accessPolicyY, layout.contentWidth, 20, Component.translatable("connect_share.friends.access"), @@ -807,7 +824,7 @@ class ShareJoinScreen( Checkbox.builder( Component.translatable("connect_share.friends.share_worlds"), font, - ).pos(layout.contentX, layout.bodyTop + 50) + ).pos(layout.contentX, form.shareWorldsY) .selected(friend.permissions.canSeeMyWorlds) .build(), ) @@ -815,7 +832,7 @@ class ShareJoinScreen( CycleButton.onOffBuilder(friend.internetDirectGuestOptIn) .create( layout.contentX, - layout.bodyTop + 100, + form.internetDirectY, layout.contentWidth, 20, Component.translatable( @@ -858,6 +875,7 @@ class ShareJoinScreen( requestOperationInProgress = false mode = Mode.FRIENDS selectedPeerId = null + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -930,7 +948,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -955,7 +973,7 @@ class ShareJoinScreen( removeConfirmation = false mode = Mode.FRIENDS selectedPeerId = null - nameValue = "" + resetFriendFormDraft() rebuildWidgets() } }.bounds( @@ -1091,8 +1109,7 @@ class ShareJoinScreen( return@launch } mode = Mode.FRIENDS - invitationValue = "" - nameValue = "" + resetFriendFormDraft() rebuildWidgets() deliverOutgoing(peerId) } @@ -1320,6 +1337,25 @@ class ShareJoinScreen( 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( 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 index 4d624a02b..8240cf673 100644 --- 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 @@ -19,6 +19,21 @@ 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 -> 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 index 69a0b7465..9e1a1030a 100644 --- 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 @@ -40,6 +40,15 @@ data class FormScreenLayout( } } +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 @@ -83,13 +92,13 @@ object AdaptiveShareLayout { fun form( screenWidth: Int, screenHeight: Int, - @Suppress("UNUSED_PARAMETER") fieldCount: 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 = 58 + val bodyTop = if (fieldCount >= 5) 52 else 58 return FormScreenLayout( contentX = contentX, contentWidth = contentWidth, @@ -102,6 +111,16 @@ object AdaptiveShareLayout { ) } + 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) 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/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 index 3a76ee730..a2fdfbd22 100644 --- 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 @@ -45,4 +45,21 @@ class AdaptiveShareLayoutTest { 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) + } +} From 194b85486556f94e4c54c6d1089fa467a9c05fe6 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 3 Aug 2026 23:09:15 +0200 Subject: [PATCH 184/188] fix(share): stabilize Java 17 direct transfers --- .../skills/connect-share-prism-e2e/SKILL.md | 10 ++++ .../impl/Libp2pTunnelTransportRuntime.java | 6 +- .../connect/tunnel/p2p/DirectP2pNodeTest.java | 57 +++++++++++++++++++ share/AGENTS.md | 9 +++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 3feda93dc..46a2820c7 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -40,6 +40,11 @@ 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. @@ -188,6 +193,11 @@ Recognize these established failure signatures: - `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 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. 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/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java index 93082a98e..84cf69edc 100644 --- a/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java +++ b/core/src/test/java/com/minekube/connect/tunnel/p2p/DirectP2pNodeTest.java @@ -124,6 +124,63 @@ void twoLoopbackNodesExchangeMinecraftShapedBytes() throws Exception { } } + @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(); diff --git a/share/AGENTS.md b/share/AGENTS.md index a7a3c6cd5..a5eb4a5b5 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -178,6 +178,15 @@ redesigned for Kotlin. `cachedRequires` metadata and usually one online first launch to download loader libraries. Kotlin for Forge must be installed from its `-all.jar`; the smaller Maven compile artifact is not a discoverable loader mod. +- Replace Prism mods by matching the JAR basename at the immediate `mods/` + level. Do not apply a `connect-share-*.jar` regex to the full absolute path: + E2E instance directory names also contain `connect-share`, so that pattern + can move Fabric API, Fabric Language Kotlin, or Kotlin for Forge by mistake. +- Keep the direct runtime on jvm-libp2p's tested Mplex default until another + muxer passes both the Java 17 multi-window regression and every real-client + adapter. Yamux on Netty 4.2 can double-release its buffered window data on + Java 17 after server login, disconnecting the player while flooding the host + with `IllegalReferenceCountException`. - Legacy Forge's final reobfuscated JAR must contain its generated Mixin refmap and name it from the loader-specific mixin config. Forge and NeoForge client resources need a compatible `pack.mcmeta`, otherwise startup can stop at a From 4663efe17631088e831e861d523c463906015b82 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 00:04:33 +0200 Subject: [PATCH 185/188] fix(share): preserve loader networking contracts --- .../skills/connect-share-prism-e2e/SKILL.md | 8 +++ share/AGENTS.md | 12 +++++ .../connect/share/ShareConnectionGateway.kt | 18 +++++-- .../connect/share/VersionedMinecraftBridge.kt | 28 +++++----- .../connect/share/AdapterContractTest.kt | 22 ++++++-- .../share/GatewayMinecraftBridgeTest.kt | 4 ++ .../share/ShareConnectionGatewayTest.kt | 53 +++++++++++++++++++ .../mixin/ServerLoginPacketListenerMixin.java | 8 ++- .../fabric/v1_20_1/ConnectShare12111Client.kt | 4 ++ .../v1_20_1/Minecraft12111LoginBridge.kt | 25 +++++++++ .../v1_20_1/VanillaMinecraft12111Transport.kt | 3 ++ .../Minecraft1201LoginContinuationTest.kt | 48 +++++++++++++++++ .../v1_20_1/Minecraft12111BridgeTest.kt | 7 ++- .../v1_21_1/VanillaMinecraft12111Transport.kt | 3 ++ .../v1_21_1/Minecraft12111BridgeTest.kt | 7 ++- .../VanillaMinecraft12111Transport.kt | 3 ++ .../v1_21_11/Minecraft12111BridgeTest.kt | 7 ++- .../v26_2/VanillaMinecraft262Transport.kt | 3 ++ .../fabric/v26_2/Minecraft262BridgeTest.kt | 5 ++ .../share/fabric/FabricShareBootstrap.kt | 7 ++- .../v1_20_1/ForgeConnectShare1201Client.kt | 5 ++ .../v1_20_1/ForgeGatewayThreadFactory.kt | 15 ++++++ .../forge/v1_20_1/ForgeLoginNegotiation.kt | 31 +++++++++++ .../v1_20_1/ForgeLoginNegotiationTest.kt | 31 +++++++++++ 24 files changed, 327 insertions(+), 30 deletions(-) create mode 100644 share/fabric-1.20.1/src/test/kotlin/com/minekube/connect/share/fabric/v1_20_1/Minecraft1201LoginContinuationTest.kt create mode 100644 share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeGatewayThreadFactory.kt create mode 100644 share/forge-1.20.1/src/main/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiation.kt create mode 100644 share/forge-1.20.1/src/test/kotlin/com/minekube/connect/share/forge/v1_20_1/ForgeLoginNegotiationTest.kt diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 46a2820c7..55cec5aa6 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -198,6 +198,14 @@ Recognize these established failure signatures: 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. diff --git a/share/AGENTS.md b/share/AGENTS.md index a5eb4a5b5..3f7438dbf 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -187,6 +187,18 @@ redesigned for Kotlin. adapter. Yamux on Netty 4.2 can double-release its buffered window data on Java 17 after server login, disconnecting the player while flooding the host with `IllegalReferenceCountException`. +- Run every gateway path that installs Minecraft's captured initializer on the + loader's logical-server thread group; thread affinity is part of the loader + contract, not an interchangeable executor. Forge derives packet side from + the thread group, so a generic always-on gateway can reject 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 instead owns loader-classified event loops supplied by the adapter. +- Admission must resume 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 that + state makes two Forge clients misclassify each other as vanilla and later + disconnect on registry custom payloads. - Legacy Forge's final reobfuscated JAR must contain its generated Mixin refmap and name it from the loader-specific mixin config. Forge and NeoForge client resources need a compatible `pack.mcmeta`, otherwise startup can stop at a 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 index 74197e00d..ed94ba739 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/ShareConnectionGateway.kt @@ -27,20 +27,24 @@ 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, - DefaultThreadFactory("Connect Share gateway local"), + minecraftThreadFactory + ?: DefaultThreadFactory("Connect Share gateway local"), ) private val directEventLoop: EventLoopGroup = NioEventLoopGroup( 1, - DefaultThreadFactory("Connect Share gateway direct"), + minecraftThreadFactory + ?: DefaultThreadFactory("Connect Share gateway direct"), ) private val directChannel: ChannelFuture @@ -302,9 +306,15 @@ class ShareConnectionGateway private constructor( } companion object { - fun bind(friendServer: FriendControlServer): + fun bind(friendServer: FriendControlServer): ShareConnectionGateway = + ShareConnectionGateway(friendServer, null) + + fun bind( + minecraftThreadFactory: ThreadFactory?, + friendServer: FriendControlServer, + ): ShareConnectionGateway = - ShareConnectionGateway(friendServer) + ShareConnectionGateway(friendServer, minecraftThreadFactory) private fun closeChannel(future: ChannelFuture?) { val channel = future?.channel() ?: return 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 index c744ce344..5701ce23b 100644 --- a/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt +++ b/share/common/src/main/kotlin/com/minekube/connect/share/VersionedMinecraftBridge.kt @@ -9,10 +9,8 @@ import io.netty.bootstrap.ServerBootstrap import io.netty.channel.Channel import io.netty.channel.ChannelFuture import io.netty.channel.ChannelInitializer -import io.netty.channel.DefaultEventLoopGroup import io.netty.channel.EventLoopGroup import io.netty.channel.local.LocalAddress -import io.netty.util.concurrent.DefaultThreadFactory import java.net.InetSocketAddress import java.net.SocketAddress @@ -76,7 +74,10 @@ open class VersionedMinecraftBridge private constructor( ) } else { local = checkNotNull(localBinder) - .bind(published.childInitializer) + .bind( + published.childInitializer, + published.eventLoopGroup, + ) validateLocal(local).fold( ifLeft = { throw IllegalStateException(it.safeMessage) @@ -260,6 +261,7 @@ fun interface MinecraftVersionTransport { interface PublishedMinecraftTransport { val address: InetSocketAddress val childInitializer: ChannelInitializer + val eventLoopGroup: EventLoopGroup fun addLocalListener(listener: LocalShareChannel) @@ -269,7 +271,10 @@ interface PublishedMinecraftTransport { } fun interface LocalShareChannelBinder { - fun bind(childInitializer: ChannelInitializer): LocalShareChannel + fun bind( + childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel } interface LocalShareChannel { @@ -283,25 +288,18 @@ interface LocalShareChannel { class NettyLocalShareChannelBinder : LocalShareChannelBinder { override fun bind( childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, ): LocalShareChannel { - val eventLoop = DefaultEventLoopGroup( - 0, - DefaultThreadFactory( - "Connect Share local", - Thread.MAX_PRIORITY, - ), - ) try { val future = ServerBootstrap() .channel(LocalServerChannelWrapper::class.java) .childHandler(childInitializer) - .group(eventLoop) + .group(eventLoopGroup) .localAddress(LocalAddress.ANY) .bind() .syncUninterruptibly() - return NettyLocalShareChannel(future, eventLoop) + return NettyLocalShareChannel(future) } catch (failure: Throwable) { - eventLoop.shutdownGracefully().syncUninterruptibly() throw failure } } @@ -309,13 +307,11 @@ class NettyLocalShareChannelBinder : LocalShareChannelBinder { private class NettyLocalShareChannel( override val future: ChannelFuture, - private val eventLoop: EventLoopGroup, ) : LocalShareChannel { override val address = future.channel().localAddress() override fun close() { future.closeChannel() - eventLoop.shutdownGracefully().syncUninterruptibly() } } 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 index 19031b87c..b280e2e12 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/AdapterContractTest.kt @@ -2,6 +2,8 @@ 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 @@ -17,7 +19,8 @@ class AdapterContractTest { @Test fun `every version bridge is loopback local repeatable and exactly released`() = runBlocking { val harness = FakeVersionTransport() - val bridge = VersionedMinecraftBridge(harness, FakeLocalBinder()) + val binder = FakeLocalBinder() + val bridge = VersionedMinecraftBridge(harness, binder) val first = bridge.open(options) assertTrue(harness.boundAddress.address.isLoopbackAddress) @@ -35,6 +38,8 @@ class AdapterContractTest { 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 @@ -68,6 +73,7 @@ class AdapterContractTest { var listenerCount = 0 var publishCount = 0 var publishedCloseCount = 0 + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) lateinit var boundAddress: InetSocketAddress override fun publish(options: ShareOptions): PublishedMinecraftTransport { @@ -79,6 +85,7 @@ class AdapterContractTest { return object : PublishedMinecraftTransport { override val address = boundAddress override val childInitializer = NoopInitializer + override val eventLoopGroup = this@FakeVersionTransport.eventLoopGroup override fun addLocalListener(listener: LocalShareChannel) { listenerCount++ @@ -104,6 +111,7 @@ class AdapterContractTest { override fun bind( childInitializer: ChannelInitializer, + eventLoopGroup: EventLoopGroup, ): LocalShareChannel = object : LocalShareChannel { override val address: SocketAddress = LocalAddress("failing-local") @@ -115,11 +123,17 @@ class AdapterContractTest { } private class FakeLocalBinder : LocalShareChannelBinder { + val eventLoopGroups = mutableListOf() + override fun bind( childInitializer: ChannelInitializer, - ): LocalShareChannel = object : LocalShareChannel { - override val address: SocketAddress = LocalAddress("adapter-contract") - override fun close() = Unit + eventLoopGroup: EventLoopGroup, + ): LocalShareChannel { + eventLoopGroups += eventLoopGroup + return object : LocalShareChannel { + override val address: SocketAddress = LocalAddress("adapter-contract") + override fun close() = Unit + } } } 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 index 2ece42efb..085973437 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/GatewayMinecraftBridgeTest.kt @@ -7,6 +7,8 @@ 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 @@ -131,6 +133,7 @@ class GatewayMinecraftBridgeTest { ) } } + override val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) var onAdd: () -> Unit = {} var onRemove: () -> Unit = {} var closed = false @@ -145,6 +148,7 @@ class GatewayMinecraftBridgeTest { override fun close() { closed = true + eventLoopGroup.shutdownGracefully().syncUninterruptibly() } } 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 index 4f6376fa6..94cabfbf3 100644 --- a/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt +++ b/share/common/src/test/kotlin/com/minekube/connect/share/ShareConnectionGatewayTest.kt @@ -17,19 +17,72 @@ 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 { 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 index d2e84b243..359bb8e6b 100644 --- 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 @@ -60,7 +60,9 @@ public abstract class ServerLoginPacketListenerMixin { connectShare$beginAdmission(profile); } else { connectShare$admissionAllowed = true; - handleAcceptedLogin(); + Minecraft1201LoginBridge.continueApprovedLogin( + this, + this::handleAcceptedLogin); } callback.cancel(); } @@ -86,7 +88,9 @@ public abstract class ServerLoginPacketListenerMixin { connectShare$admissionStarted = true; Runnable allow = () -> { connectShare$admissionAllowed = true; - handleAcceptedLogin(); + Minecraft1201LoginBridge.continueApprovedLogin( + this, + this::handleAcceptedLogin); }; if (Minecraft1201LoginBridge.hasDirectSession(connection)) { Minecraft1201LoginBridge.requestDirectAdmission( 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 index a58bf53cb..70f3c026a 100644 --- 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 @@ -32,6 +32,7 @@ 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 @@ -129,6 +130,7 @@ class ConnectShare1201Runtime( friendActivity = activitySnapshot::get, compatibilityProfile = { compatibilityProfile }, friendJoinTarget = joinTargetSnapshot::get, + minecraftThreadFactory = platform.gatewayThreadFactory, bridgeFactory = { admission, admissionScope, @@ -480,6 +482,8 @@ interface ConnectShare1201Platform { val loader: ModLoader val loadedMods: List val configDirectory: Path + val gatewayThreadFactory: ThreadFactory? + get() = null fun onEndClientTick(callback: (Minecraft) -> Unit) 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 index e450b370b..6780ef045 100644 --- 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 @@ -20,6 +20,12 @@ 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 @@ -65,6 +71,20 @@ object Minecraft1201LoginBridge { 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, @@ -171,4 +191,9 @@ object Minecraft1201LoginBridge { 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/VanillaMinecraft12111Transport.kt b/share/fabric-1.20.1/src/main/kotlin/com/minekube/connect/share/fabric/v1_20_1/VanillaMinecraft12111Transport.kt index f9829e240..f4d93caab 100644 --- 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 @@ -8,6 +8,7 @@ import com.minekube.connect.share.fabric.v1_20_1.mixin.ServerConnectionListenerA 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 @@ -68,6 +69,7 @@ internal class VanillaMinecraft1201Transport( loopback = loopback, address = address, childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, ) } catch (failure: Throwable) { captureLease.close() @@ -114,6 +116,7 @@ private class PublishedVanillaTransport( 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) { 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 index ed2ffbc12..8ed24c4f1 100644 --- 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 @@ -4,6 +4,8 @@ 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 @@ -58,7 +60,7 @@ class Minecraft1201BridgeTest { val transport = FakeMinecraftTransport() val bridge = Minecraft1201Bridge( transport, - LocalShareChannelBinder { + LocalShareChannelBinder { _, _ -> throw IllegalStateException("local bind failed") }, ) @@ -75,6 +77,7 @@ class Minecraft1201BridgeTest { var publishedPort = -1 var listenerCount = 0 lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) override fun publish(options: ShareOptions): PublishedMinecraftTransport { check(publishedPort == -1) @@ -84,6 +87,7 @@ class Minecraft1201BridgeTest { 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++ @@ -106,6 +110,7 @@ class Minecraft1201BridgeTest { 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 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 index 12bef8b02..3ffa59eba 100644 --- 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 @@ -8,6 +8,7 @@ import com.minekube.connect.share.fabric.v1_21_1.mixin.ServerConnectionListenerA 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 @@ -68,6 +69,7 @@ internal class VanillaMinecraft1211Transport( loopback = loopback, address = address, childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, ) } catch (failure: Throwable) { captureLease.close() @@ -114,6 +116,7 @@ private class PublishedVanillaTransport( 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) { 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 index a8d3ed5b8..a7640877e 100644 --- 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 @@ -4,6 +4,8 @@ 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 @@ -58,7 +60,7 @@ class Minecraft1211BridgeTest { val transport = FakeMinecraftTransport() val bridge = Minecraft1211Bridge( transport, - LocalShareChannelBinder { + LocalShareChannelBinder { _, _ -> throw IllegalStateException("local bind failed") }, ) @@ -75,6 +77,7 @@ class Minecraft1211BridgeTest { var publishedPort = -1 var listenerCount = 0 lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) override fun publish(options: ShareOptions): PublishedMinecraftTransport { check(publishedPort == -1) @@ -84,6 +87,7 @@ class Minecraft1211BridgeTest { 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++ @@ -106,6 +110,7 @@ class Minecraft1211BridgeTest { 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 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 index 9b39bb60a..ce254f130 100644 --- 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 @@ -8,6 +8,7 @@ import com.minekube.connect.share.fabric.v1_21_11.mixin.ServerConnectionListener 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 @@ -68,6 +69,7 @@ internal class VanillaMinecraft12111Transport( loopback = loopback, address = address, childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, ) } catch (failure: Throwable) { captureLease.close() @@ -114,6 +116,7 @@ private class PublishedVanillaTransport( 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) { 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 index 4d51c7893..eed36753b 100644 --- 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 @@ -4,6 +4,8 @@ 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 @@ -58,7 +60,7 @@ class Minecraft12111BridgeTest { val transport = FakeMinecraftTransport() val bridge = Minecraft12111Bridge( transport, - LocalShareChannelBinder { + LocalShareChannelBinder { _, _ -> throw IllegalStateException("local bind failed") }, ) @@ -75,6 +77,7 @@ class Minecraft12111BridgeTest { var publishedPort = -1 var listenerCount = 0 lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) override fun publish(options: ShareOptions): PublishedMinecraftTransport { check(publishedPort == -1) @@ -84,6 +87,7 @@ class Minecraft12111BridgeTest { 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++ @@ -106,6 +110,7 @@ class Minecraft12111BridgeTest { 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 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 index e7dba1943..f9c33834c 100644 --- 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 @@ -9,6 +9,7 @@ import com.minekube.connect.share.fabric.v26_2.mixin.ServerConnectionListenerAcc 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 @@ -71,6 +72,7 @@ internal class VanillaMinecraft262Transport( loopback = loopback, address = address, childInitializer = captured.childInitializer, + eventLoopGroup = captured.eventLoopGroup, ) } catch (failure: Throwable) { captureLease.close() @@ -119,6 +121,7 @@ private class PublishedVanillaTransport( 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) { 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 index 9035b2321..215e4353b 100644 --- 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 @@ -4,6 +4,8 @@ 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 @@ -44,6 +46,7 @@ class Minecraft262BridgeTest { var listenerCount = 0 var publishCount = 0 lateinit var boundAddress: InetSocketAddress + val eventLoopGroup: EventLoopGroup = DefaultEventLoopGroup(1) override fun publish(options: ShareOptions): PublishedMinecraftTransport { check(publishedPort == -1) @@ -54,6 +57,7 @@ class Minecraft262BridgeTest { return object : PublishedMinecraftTransport { override val address = boundAddress override val childInitializer = NoopInitializer + override val eventLoopGroup = this@FakeMinecraftTransport.eventLoopGroup override fun addLocalListener(listener: LocalShareChannel) { listenerCount++ @@ -76,6 +80,7 @@ class Minecraft262BridgeTest { 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 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 index 232215398..c2ae72012 100644 --- 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 @@ -30,6 +30,7 @@ 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 @@ -73,6 +74,7 @@ object FabricShareBootstrap { environment: Map = System.getenv(), logger: ConnectLogger = FabricConnectLogger(), httpClient: OkHttpClient = OkHttpClient(), + minecraftThreadFactory: ThreadFactory? = null, ): ConnectShareInstallation { val viewModelReference = AtomicReference() val diagnostics = ShareJoinDiagnostics() @@ -161,7 +163,10 @@ object FabricShareBootstrap { presencePrivacy = { preferences.get().presence }, joinTarget = friendJoinTarget, ) - val gateway = ShareConnectionGateway.bind(friendRequestServer) + val gateway = ShareConnectionGateway.bind( + minecraftThreadFactory = minecraftThreadFactory, + friendServer = friendRequestServer, + ) var browser: FabricShareBrowser? = null var controlPlane: ConnectControlPlane? = null var directControlPlane: DirectControlPlane? = null 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 index fa10c48c8..16e75e354 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -24,6 +25,9 @@ class ForgeConnectShare1201Client { private val platform = ForgePlatform() init { + Minecraft1201LoginBridge.installLoginContinuation( + ForgeLoginNegotiation::continueApprovedLogin, + ) ConnectShare1201Runtime(platform).initialize() MinecraftForge.EVENT_BUS.register(platform) } @@ -46,6 +50,7 @@ class ForgeConnectShare1201Client { ) } override val configDirectory: Path = FMLPaths.CONFIGDIR.get() + override val gatewayThreadFactory = ForgeGatewayThreadFactory override fun onEndClientTick(callback: (Minecraft) -> Unit) { tickCallbacks += callback 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/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, + } +} From 3295dfae0f4a33b109b9b5cab76613eafc9292df Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 00:05:11 +0200 Subject: [PATCH 186/188] docs(share): record final adapter product matrix --- docs/connect-share-adoption-evidence.md | 23 +++++++++++++++++++++-- docs/connect-share-known-issues.md | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 8d5ce3ff0..2c12d4c7f 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -101,6 +101,25 @@ Status meanings: 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 @@ -132,7 +151,7 @@ Status meanings: | 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 | Deterministic proof | Fabric 1.20.1/1.21.1/1.21.11/26.2, Forge 1.20.1, and NeoForge 1.21.1 all built and passed packaged artifact tests | Real-client startup and join evidence remains required for every release target | +| 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 package KotlinForForge in their distributable 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 | @@ -149,7 +168,7 @@ Status meanings: | 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 | Gap | Six packaged adapter suites pass and Fabric 26.2 clean-head direct join is proven | Fabric 1.20.1/1.21.1/1.21.11 plus Forge 1.20.1 and NeoForge 1.21.1 startup/join, then OS/architecture breadth | +| 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 diff --git a/docs/connect-share-known-issues.md b/docs/connect-share-known-issues.md index 65bf40be1..951fe85da 100644 --- a/docs/connect-share-known-issues.md +++ b/docs/connect-share-known-issues.md @@ -9,7 +9,7 @@ item is resolved and its evidence is linked. | 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 | Clean packaged direct, Connect fallback, no-mod approval, and no-mod terminal-denial product proof exists for Fabric 26.2 on macOS arm64; the remaining loader/version/OS/architecture matrix is deterministic only | Treat other artifacts as prerelease until their real-client startup and join gates pass | +| 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 From da36bd252bd0d9aaa2555ef4f76320c4358cbc28 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Tue, 4 Aug 2026 00:20:25 +0200 Subject: [PATCH 187/188] no-mistakes(document): fix stale Share doc facts, UI labels, em dashes --- .../skills/connect-share-prism-e2e/SKILL.md | 9 +-- docs/connect-share-adoption-evidence.md | 24 ++++---- docs/connect-share-launch.md | 14 ++--- docs/connect-share-marketplace-kit.md | 2 +- docs/connect-share-testing.md | 4 +- docs/connect-share.md | 7 ++- .../2026-07-30-connect-share-singleplayer.md | 60 +++++++++---------- 7 files changed, 61 insertions(+), 59 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 55cec5aa6..21cc474b0 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -83,7 +83,7 @@ Start it after the host world is ready: ```sh LIVE_DATA= \ -LIVE_PORT_FILE= \ +LIVE_TARGET_FILE= \ LIVE_HOST_LOG= \ LIVE_GUEST_LOG= \ LIVE_PLAYER_NAME= \ @@ -96,8 +96,9 @@ The harness keeps its pre-launch log snapshot immutable across Prism's 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 port written to -`LIVE_PORT_FILE`. It proves, in order: +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). It proves, in order: 1. mDNS discovers the saved confirmed friend's peer identity. 2. Authenticated friend control reports `HOSTING_WORLD`. @@ -238,5 +239,5 @@ 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 +failure signatures, and authoritative files - not transient PIDs, ports, local absolute paths, endpoint secrets, or raw debugging noise. diff --git a/docs/connect-share-adoption-evidence.md b/docs/connect-share-adoption-evidence.md index 2c12d4c7f..1d065657f 100644 --- a/docs/connect-share-adoption-evidence.md +++ b/docs/connect-share-adoption-evidence.md @@ -121,7 +121,7 @@ Status meanings: 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 +## #95 - one-click presence, request, approval, and join | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -134,7 +134,7 @@ Status meanings: | 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 +## #96 - detect modpack mismatch before joining | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -146,19 +146,19 @@ Status meanings: | 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 +## #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 package KotlinForForge in their distributable artifact | Prove fresh one-click installs through published Modrinth metadata and Prism on all supported loader families | +| 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 +## #98 - reliable joining and actionable recovery | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -170,7 +170,7 @@ Status meanings: | 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 +## #99 - let friends join without installing the mod | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -182,7 +182,7 @@ Status meanings: | 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 +## #100 - privacy, permissions, and relationship safety | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -195,7 +195,7 @@ Status meanings: | 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 +## #103 - follow a friend into the next joinable world | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -207,7 +207,7 @@ Status meanings: | 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 +## #117 - one-click HTTPS invite/install/resume handoff | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -217,7 +217,7 @@ Status meanings: | 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 +## #118 - staged launch, measurement, modpacks, and creators | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -228,7 +228,7 @@ Status meanings: | 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 +## #119 - global Connect fallback operations and security review | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| @@ -240,7 +240,7 @@ Status meanings: | 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 +## #120 - encrypted identity and friend recovery | Acceptance criterion | Status | Evidence | Remaining proof | |---|---|---|---| diff --git a/docs/connect-share-launch.md b/docs/connect-share-launch.md index 4bcd1c9be..c8e7401f2 100644 --- a/docs/connect-share-launch.md +++ b/docs/connect-share-launch.md @@ -11,11 +11,11 @@ or the exact packaged-client evidence matrix. | 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 | +| 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 @@ -46,8 +46,8 @@ 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 +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; diff --git a/docs/connect-share-marketplace-kit.md b/docs/connect-share-marketplace-kit.md index a31a0019f..4fa443aa6 100644 --- a/docs/connect-share-marketplace-kit.md +++ b/docs/connect-share-marketplace-kit.md @@ -35,7 +35,7 @@ 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 +Connect Share is a focused universal party layer - not a cosmetics, chat, or server-management suite. ## Supported release metadata diff --git a/docs/connect-share-testing.md b/docs/connect-share-testing.md index 287e90b6f..57b7cb2e3 100644 --- a/docs/connect-share-testing.md +++ b/docs/connect-share-testing.md @@ -31,7 +31,7 @@ the marketplace dependency metadata. ## Identity reuse and import -1. Start a singleplayer world and choose **Share with friends**. +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. @@ -71,7 +71,7 @@ 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 with friends**, and leave +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 diff --git a/docs/connect-share.md b/docs/connect-share.md index d5276a69e..0ddd1240f 100644 --- a/docs/connect-share.md +++ b/docs/connect-share.md @@ -50,13 +50,14 @@ or blocking cannot be bypassed with an old attempt. - 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 current server or world** hides both multiplayer server + **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 Every Time**, **Auto-Accept**, or **Never - Allow**. The default is Ask Every Time. +- 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. diff --git a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md index 5f7ef4cfe..da14712d4 100644 --- a/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md +++ b/docs/superpowers/plans/2026-07-30-connect-share-singleplayer.md @@ -35,45 +35,45 @@ This plan is the independently testable singleplayer-through-Connect slice. It e ### 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. +- `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`. +- `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. +- `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. +- `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 From d53c352b815612ecf7626f2a9c93bd1af5650ed5 Mon Sep 17 00:00:00 2001 From: "no-mistakes[bot]" Date: Tue, 4 Aug 2026 00:25:26 +0200 Subject: [PATCH 188/188] no-mistakes(document): consolidate Share E2E doc into invariants and owner pointers --- .../skills/connect-share-prism-e2e/SKILL.md | 6 +- share/AGENTS.md | 235 ++++++------------ 2 files changed, 86 insertions(+), 155 deletions(-) diff --git a/.agents/skills/connect-share-prism-e2e/SKILL.md b/.agents/skills/connect-share-prism-e2e/SKILL.md index 21cc474b0..42f684423 100644 --- a/.agents/skills/connect-share-prism-e2e/SKILL.md +++ b/.agents/skills/connect-share-prism-e2e/SKILL.md @@ -98,7 +98,11 @@ 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). It proves, in order: +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`. diff --git a/share/AGENTS.md b/share/AGENTS.md index 3f7438dbf..289d60c4d 100644 --- a/share/AGENTS.md +++ b/share/AGENTS.md @@ -67,164 +67,91 @@ redesigned for Kotlin. ## Prism Two-Client E2E -- Prism can drive the live flow without UI automation. Launch the host with - `prismlauncher --launch --profile --world ` and a - distinct offline guest with - `prismlauncher --launch --offline --server `. - `--offline ` is authoritative; editing `InstanceAccountId` while Prism - runs is not, because Prism rewrites it. -- A matching Prism JVM PID does not prove a fresh launch. Snapshot - `minecraft/logs/latest.log` before launch, require a newer mtime plus the - expected world/runtime markers, and treat an old JVM with an unchanged log as - an occupied stale instance. Before terminating one, resolve exactly one PID - by its instance working directory; never kill a broad Java process set. -- Prove the flow in layers: mDNS discovery, authenticated friend activity, - Minecraft status when host privacy permits it, then follow [the testing - guide](../docs/connect-share-testing.md) for the real two-client login - evidence gates. Control-plane reachability or a status response does not - prove that the world is joinable. `dns-sd -B - _minekube-connect-share._tcp local` and `jcmd GC.class_histogram` are - useful diagnostics for discovery and live `ShareState`/transport objects. -- Run only one Gradle invocation at a time in a worktree. Concurrent test tasks - share `build/test-results` and can delete one another's in-progress binary - results, producing a false infrastructure failure. -- A `DirectP2pProxy` target is currently one-shot. A status probe consumes it; - open a separate target for gameplay and keep that target alive until the - Minecraft connection finishes. Never reuse the friend-control target for a - status probe or login. +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. Never promote raw Minecraft status into UI - presence without a matching privacy-filtered activity response. The gateway - rejects status whenever online, playing, or the current server/world name is - hidden. A capability route may answer status only when all three are visible; - this must never promote an unknown or pending identity into social presence. - Login remains independently admissible so a privacy-safe join request can - still succeed. + 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`; otherwise friends see a world that cannot yet - accept them. -- `ShareConnectionGateway` installs Minecraft's captured Netty initializer - after its accepted channel is already active. Any change to that dispatch - must preserve a focused test proving newly installed handlers receive the - required active lifecycle before the first Minecraft bytes. + 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. Otherwise an offline Prism friend is rejected as "Invalid - session" before admission runs. `ONLINE` direct sessions must never silently - downgrade. -- Persistent friend cards must retain signed direct candidates and friend - control must try those candidates after mDNS, without ever using Connect as a - social relay. Copying a friend link is the disclosure boundary for those - routes; removal must revoke both admission grants and reciprocal-card proofs. -- Invitation renewal is not complete when only mDNS receives a fresh token. - Every copy action must resolve the current handle invitation so a long-running - share never copies the original expired token. -- For no-click friend-request E2E, temporarily enable automatic joins only for - the confirmed test friend, send the real libp2p join request, and restore the - permission afterwards. Keep machine-specific instance paths and credentials - in environment variables, never in committed tests or scripts. -- A vanilla no-mod Connect join has no signed direct-peer proof. Its Connect - profile may therefore require an ordinary pending admission even when a - same-named offline friend is set to auto-accept; do not weaken UUID/peer - matching to make a test pass. An unattended local proof may attach a - temporary, uncommitted driver that resolves the existing `ShareViewModel`, - asserts exactly one pending admission, and invokes its normal `allow` action. - Emit only stage/result booleans, remove the driver afterwards, and never ship - a production bypass or log the pending identity/request ID. -- Connect's no-mod session admission must finish before vanilla's own - connection timeout. Preserve a deadline buffer, cancel the pending host - request when it expires, and test the guest-visible actionable denial; - generic `Timed out` is a failed UX result. Encode an intentional denial as - `PermissionDenied` with the safe copy repeated in a - `google.rpc.LocalizedMessage` detail: Moxy intentionally never shows a - connector-controlled raw status message. A bounded `PermissionDenied` - response proves the proposal reached this connector, so diagnose host - admission rather than session delivery. -- An approved gameplay join may enable automatic friend-card exchange only - when its proof carries a direct peer ID and the subsequently supplied, - signature-verified invitation names that same peer. Name and Minecraft UUID - are not sufficient for offline or Connect-only sessions; fail closed rather - than turning an unbound admission into `AUTO_ACCEPT` friendship. -- `approveNextJoin` grants are one-shot admission capabilities, not durable - friend state. Expire them within the admission timeout, deduplicate them, - and bound the queue by `maxPending`; when full, evict the oldest grant so a - requester cannot accumulate arbitrary UUID grants or grow memory without - bound. -- `PrismFriendJoinE2ETest` is the opt-in live harness. Start the host first, - then follow [the testing guide](../docs/connect-share-testing.md) for the - complete two-client launch and evidence gates. Keep machine-specific paths - in `LIVE_DATA`, `LIVE_TARGET_FILE`, `LIVE_HOST_LOG`, and `LIVE_GUEST_LOG` - environment variables (`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. -- Invoke the live harness with `--rerun-tasks`. Its environment variables are - intentionally not task inputs, so an up-to-date result is not live evidence. -- Keep only one host and one guest identity active during a live run. Cloning a - Prism instance copies `share-libp2p-identity.key`; simultaneously advertising - that same peer identity from several processes makes mDNS routing ambiguous - and can produce misleading libp2p stream failures. + 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 that entry when the same - peer advertises a changed address; deduplicating by share ID alone or - suppressing same-invitation address changes can evict the saved friend's - control route immediately after authenticated activity and make status/join - readiness appear flaky. -- Manually constructed Prism Forge/NeoForge components need correct - `cachedRequires` metadata and usually one online first launch to download - loader libraries. Kotlin for Forge must be installed from its `-all.jar`; - the smaller Maven compile artifact is not a discoverable loader mod. -- Replace Prism mods by matching the JAR basename at the immediate `mods/` - level. Do not apply a `connect-share-*.jar` regex to the full absolute path: - E2E instance directory names also contain `connect-share`, so that pattern - can move Fabric API, Fabric Language Kotlin, or Kotlin for Forge by mistake. -- Keep the direct runtime on jvm-libp2p's tested Mplex default until another - muxer passes both the Java 17 multi-window regression and every real-client - adapter. Yamux on Netty 4.2 can double-release its buffered window data on - Java 17 after server login, disconnecting the player while flooding the host - with `IllegalReferenceCountException`. -- Run every gateway path that installs Minecraft's captured initializer on the - loader's logical-server thread group; thread affinity is part of the loader - contract, not an interchangeable executor. Forge derives packet side from - the thread group, so a generic always-on gateway can reject 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 instead owns loader-classified event loops supplied by the adapter. -- Admission must resume 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 that - state makes two Forge clients misclassify each other as vanilla and later - disconnect on registry custom payloads. -- Legacy Forge's final reobfuscated JAR must contain its generated Mixin refmap - and name it from the loader-specific mixin config. Forge and NeoForge client - resources need a compatible `pack.mcmeta`, otherwise startup can stop at a - resource-pack warning before quick-play E2E begins. -- Visual QA is keyboard-only at both the normal Prism window size and 640x400. - A focused Minecraft `EditBox` hides its hint, so every input needs a - persistent label; split pause-menu buttons must keep copy within their - 100-pixel logical width. The repository Prism skill owns the capture and - focus-order procedure. -- Treat add-link and manage-friend values as separate form sessions. Entering - Add from the Friends list, leaving Manage, or completing/removing/blocking a - relationship must clear name, invitation, offline-mode, and internet-direct - draft state; only the Add → Connection options → Add round trip preserves it. - On the pause screen, visual placement does not change keyboard order: remove - and re-add the vanilla disconnect button so Share and Friends are focused - before the destructive exit action. -- Recovery export/import must run only against the fixed Share allowlist and - while sharing is stopped. A selected 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 test simulated interruption. Never - print archive paths, contents, passwords, identities, or tokens as evidence. -- Do not apply Shadow's generic `minimize()` to the isolated libp2p payload. + 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 through reflection and DSL entry points that static minimization does - not see. Any payload-size reduction must keep cross-platform natives and be - proved by constructing, starting, publishing, and inspecting between two - peers loaded from the exact packaged artifact. A constructor-only classloader - test is insufficient. + 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`.