From f1aa8746a9ff2890f3e223a80fac69b23d17d93e Mon Sep 17 00:00:00 2001 From: Fuper Date: Wed, 5 Mar 2025 05:36:14 +0600 Subject: [PATCH 1/4] Removed the use of the "deprecated" function --- .../iceadapter/ice/PeerIceModule.java | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java index b3a97f0..c4413e7 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java @@ -480,23 +480,8 @@ void onFaDataReceived(byte[] faData, int length) { void sendViaIce(byte[] data, int offset, int length) { if (connected && component != null) { try { - component - .getSelectedPair() - .getIceSocketWrapper() - .send(new DatagramPacket( - data, - offset, - length, - component - .getSelectedPair() - .getRemoteCandidate() - .getTransportAddress() - .getAddress(), - component - .getSelectedPair() - .getRemoteCandidate() - .getTransportAddress() - .getPort())); + component.getComponentSocket() + .send(new DatagramPacket(data, offset, length)); } catch (IOException e) { log.warn("{} Failed to send data via ICE", getLogPrefix(), e); onConnectionLost(); @@ -518,10 +503,7 @@ public void listener() { while (!Thread.currentThread().isInterrupted() && IceAdapter.getGameSession() == peer.getGameSession()) { try { DatagramPacket packet = new DatagramPacket(data, data.length); - localComponent - .getSelectedPair() - .getIceSocketWrapper() - .getUDPSocket() + localComponent.getComponentSocket() .receive(packet); if (packet.getLength() == 0) { From 30a8077a723e208c9f4be256e6e0b271619d296d Mon Sep 17 00:00:00 2001 From: Fuper Date: Mon, 13 Oct 2025 23:42:18 +0600 Subject: [PATCH 2/4] Revert "Removed the use of the "deprecated" function" This reverts commit f1aa8746a9ff2890f3e223a80fac69b23d17d93e. --- .../iceadapter/ice/PeerIceModule.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java index c4413e7..b3a97f0 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java @@ -480,8 +480,23 @@ void onFaDataReceived(byte[] faData, int length) { void sendViaIce(byte[] data, int offset, int length) { if (connected && component != null) { try { - component.getComponentSocket() - .send(new DatagramPacket(data, offset, length)); + component + .getSelectedPair() + .getIceSocketWrapper() + .send(new DatagramPacket( + data, + offset, + length, + component + .getSelectedPair() + .getRemoteCandidate() + .getTransportAddress() + .getAddress(), + component + .getSelectedPair() + .getRemoteCandidate() + .getTransportAddress() + .getPort())); } catch (IOException e) { log.warn("{} Failed to send data via ICE", getLogPrefix(), e); onConnectionLost(); @@ -503,7 +518,10 @@ public void listener() { while (!Thread.currentThread().isInterrupted() && IceAdapter.getGameSession() == peer.getGameSession()) { try { DatagramPacket packet = new DatagramPacket(data, data.length); - localComponent.getComponentSocket() + localComponent + .getSelectedPair() + .getIceSocketWrapper() + .getUDPSocket() .receive(packet); if (packet.getLength() == 0) { From 5adb99a8114ca7682e9adb70eed1107624fd523c Mon Sep 17 00:00:00 2001 From: Fuper Date: Wed, 24 Dec 2025 04:52:17 +0600 Subject: [PATCH 3/4] Update Ice --- build.gradle | 4 + client/build.gradle | 26 +- client/src/main/java/client/TestClient.java | 2 +- .../src/main/java/client/ice/ICEAdapter.java | 5 +- ice-adapter/build.gradle | 14 +- .../com/faforever/iceadapter/IceAdapter.java | 179 ++++-- .../com/faforever/iceadapter/IceOptions.java | 31 +- .../com/faforever/iceadapter/LogoUtils.java | 35 ++ .../com/faforever/iceadapter/UiStarter.java | 22 + .../com/faforever/iceadapter/debug/Debug.java | 60 +- .../iceadapter/debug/DebugFacade.java | 8 +- .../iceadapter/debug/DebugWindow.java | 379 ----------- .../debug/DebugWindowController.java | 113 ---- .../faforever/iceadapter/debug/Debugger.java | 6 +- .../iceadapter/debug/TelemetryDebugger.java | 190 +++--- .../iceadapter/debug/TextAreaLogAppender.java | 80 --- .../iceadapter/dto/IceServerView.java | 27 + .../faforever/iceadapter/dto/PeerView.java | 103 +++ .../iceadapter/gpgnet/FaDataInputStream.java | 35 +- .../iceadapter/gpgnet/FaDataOutputStream.java | 62 +- .../iceadapter/gpgnet/GPGNetServer.java | 332 +++++----- .../iceadapter/ice/AgentSuccessMonitor.java | 11 + .../iceadapter/ice/CandidatesMessage.java | 17 +- .../faforever/iceadapter/ice/GameSession.java | 272 ++++---- .../iceadapter/ice/IceGameSession.java | 28 + .../faforever/iceadapter/ice/IceServer.java | 142 ++++- .../iceadapter/ice/IceServerChecker.java | 49 ++ .../faforever/iceadapter/ice/IceState.java | 11 +- .../faforever/iceadapter/ice/ModuleBase.java | 22 + .../com/faforever/iceadapter/ice/Peer.java | 146 ----- .../ice/PeerConnectionSuccessMonitor.java | 99 +++ .../ice/PeerConnectivityCheckerModule.java | 147 ----- .../iceadapter/ice/PeerIceModule.java | 586 ------------------ .../iceadapter/ice/PeerTurnRefreshModule.java | 98 --- .../iceadapter/ice/peer/IceAgentStrategy.java | 20 + .../faforever/iceadapter/ice/peer/Peer.java | 277 +++++++++ .../ice/peer/PeerEventListener.java | 38 ++ .../iceadapter/ice/peer/PeerModule.java | 67 ++ .../ice/peer/modules/AllowCombination.java | 16 + .../ice/peer/modules/EventBusModule.java | 112 ++++ .../ice/peer/modules/fa/FaToPeerModule.java | 130 ++++ .../ice/peer/modules/fa/PeerToFaModule.java | 63 ++ .../modules/ice/PeerToPeerListenerModule.java | 134 ++++ .../modules/ice/PeerToPeerSenderModule.java | 65 ++ .../peer/modules/info/RttCalculateModule.java | 43 ++ .../other/AutoSettingAllowCandidates.java | 44 ++ .../other/ChangeIceStrategyModule.java | 43 ++ .../peer/modules/other/FASocketModule.java | 82 +++ .../other/PeerConnectivityCheckerModule.java | 113 ++++ .../faforever/iceadapter/rpc/RPCHandler.java | 97 +-- .../faforever/iceadapter/rpc/RPCService.java | 43 +- .../iceadapter/services/ConnectService.java | 14 + .../iceadapter/services/IceAsync.java | 13 + .../iceadapter/services/IceTrigger.java | 24 + .../iceadapter/services/UIAdapter.java | 145 +++++ .../services/impl/ConnectServiceCommon.java | 284 +++++++++ .../impl/ConnectServiceControlledImpl.java | 115 ++++ .../services/impl/ConnectServiceHandler.java | 54 ++ .../impl/ConnectServiceNotControlledImpl.java | 109 ++++ .../services/impl/IceAsyncImpl.java | 59 ++ .../services/impl/UIAdapterImpl.java | 223 +++++++ .../iceadapter/telemetry/ConnectToPeer.java | 8 +- .../telemetry/DisconnectFromPeer.java | 8 +- .../telemetry/OutgoingMessageV1.java | 3 + .../iceadapter/telemetry/RegisterAsPeer.java | 7 +- .../telemetry/UpdateCoturnList.java | 8 +- .../iceadapter/telemetry/UpdateGameState.java | 8 +- .../telemetry/UpdateGpgnetState.java | 8 +- .../telemetry/UpdatePeerConnectivity.java | 8 +- .../iceadapter/telemetry/UpdatePeerState.java | 10 +- .../iceadapter/ui/IceServerWindow.java | 82 +++ .../faforever/iceadapter/ui/IceWindow.java | 87 +++ .../iceadapter/{debug => ui}/InfoWindow.java | 56 +- .../ui/controller/IceServerController.java | 112 ++++ .../controller}/InfoWindowController.java | 25 +- .../ui/controller/WindowController.java | 313 ++++++++++ .../iceadapter/util/CandidateUtil.java | 123 ++-- .../iceadapter/util/DatagramSocketUtils.java | 46 ++ .../iceadapter/util/ExecutorHolder.java | 18 +- .../faforever/iceadapter/util/IceUtils.java | 21 + .../faforever/iceadapter/util/LockUtil.java | 15 +- .../faforever/iceadapter/util/PingUtil.java | 28 + .../iceadapter/util/PingWrapper.java | 15 +- .../faforever/iceadapter/util/TrayIcon.java | 76 ++- .../src/main/resources/IceServerTable.fxml | 23 + .../src/main/resources/debugWindow.fxml | 81 --- ice-adapter/src/main/resources/iceWindow.fxml | 116 ++++ .../src/main/resources/infoWindow.fxml | 3 +- ice-adapter/src/main/resources/logback.xml | 9 +- server/build.gradle | 3 + server/src/main/resources/logback.xml | 26 + 91 files changed, 4776 insertions(+), 2338 deletions(-) create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/LogoUtils.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/UiStarter.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindow.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindowController.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/debug/TextAreaLogAppender.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/dto/IceServerView.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/dto/PeerView.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/AgentSuccessMonitor.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceGameSession.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServerChecker.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/ModuleBase.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/Peer.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectionSuccessMonitor.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectivityCheckerModule.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java delete mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerTurnRefreshModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/IceAgentStrategy.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/Peer.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerEventListener.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/AllowCombination.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/EventBusModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/FaToPeerModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/PeerToFaModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerListenerModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerSenderModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/info/RttCalculateModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/AutoSettingAllowCandidates.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/ChangeIceStrategyModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/FASocketModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/PeerConnectivityCheckerModule.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/ConnectService.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/IceAsync.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/IceTrigger.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/UIAdapter.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceCommon.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceControlledImpl.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceHandler.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceNotControlledImpl.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/IceAsyncImpl.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/UIAdapterImpl.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceServerWindow.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceWindow.java rename ice-adapter/src/main/java/com/faforever/iceadapter/{debug => ui}/InfoWindow.java (59%) create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/IceServerController.java rename ice-adapter/src/main/java/com/faforever/iceadapter/{debug => ui/controller}/InfoWindowController.java (84%) create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/WindowController.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/util/DatagramSocketUtils.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/util/IceUtils.java create mode 100644 ice-adapter/src/main/java/com/faforever/iceadapter/util/PingUtil.java create mode 100644 ice-adapter/src/main/resources/IceServerTable.fxml delete mode 100644 ice-adapter/src/main/resources/debugWindow.fxml create mode 100644 ice-adapter/src/main/resources/iceWindow.fxml create mode 100644 server/src/main/resources/logback.xml diff --git a/build.gradle b/build.gradle index 975933d..dedb944 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,12 @@ plugins { id 'java' id 'com.diffplug.spotless' version '6.25.0' id 'com.github.johnrengelman.shadow' version '7.1.2' + id 'org.openjfx.javafxplugin' version '0.1.0' } +javafx { + modules = ['javafx.controls', 'javafx.fxml'] +} group 'com.faforever' version '1.0-SNAPSHOT' diff --git a/client/build.gradle b/client/build.gradle index be56980..a431154 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'java' apply plugin: 'com.github.johnrengelman.shadow' +apply plugin: 'org.openjfx.javafxplugin' group 'com.faforever' version '1.0-SNAPSHOT' @@ -15,19 +16,28 @@ dependencies { annotationProcessor("org.projectlombok:lombok:$lombokVersion") implementation("org.projectlombok:lombok:$lombokVersion") - implementation("org.openjfx:javafx-base:${javafxVersion}:${javafxPlatform}") - implementation("org.openjfx:javafx-controls:${javafxVersion}:${javafxPlatform}") - implementation("org.openjfx:javafx-graphics:${javafxVersion}:${javafxPlatform}") - implementation("org.openjfx:javafx-fxml:${javafxVersion}:${javafxPlatform}") - implementation("org.openjfx:javafx-web:${javafxVersion}:${javafxPlatform}") - implementation("org.openjfx:javafx-media:${javafxVersion}:${javafxPlatform}") + if (javafxClasspath == "compileOnly") { + compileOnly("org.openjfx:javafx-base:${javafxVersion}:${javafxPlatform}") + compileOnly("org.openjfx:javafx-controls:${javafxVersion}:${javafxPlatform}") + compileOnly("org.openjfx:javafx-graphics:${javafxVersion}:${javafxPlatform}") + compileOnly("org.openjfx:javafx-fxml:${javafxVersion}:${javafxPlatform}") + compileOnly("org.openjfx:javafx-web:${javafxVersion}:${javafxPlatform}") + compileOnly("org.openjfx:javafx-media:${javafxVersion}:${javafxPlatform}") + } else { + implementation("org.openjfx:javafx-base:${javafxVersion}:${javafxPlatform}") + implementation("org.openjfx:javafx-controls:${javafxVersion}:${javafxPlatform}") + implementation("org.openjfx:javafx-graphics:${javafxVersion}:${javafxPlatform}") + implementation("org.openjfx:javafx-fxml:${javafxVersion}:${javafxPlatform}") + implementation("org.openjfx:javafx-web:${javafxVersion}:${javafxPlatform}") + implementation("org.openjfx:javafx-media:${javafxVersion}:${javafxPlatform}") + } implementation project(":shared") -// implementation project(":ice-adapter") + implementation project(":ice-adapter") implementation("com.sun.jna:jna:3.0.9") implementation("net.java.dev.jna:jna-platform:5.12.1") implementation("commons-io:commons-io:2.11.0") - implementation("com.github.Geosearchef:JJsonRpc:master") + implementation("com.github.faforever:JJsonRpc:37669e0fed") implementation("com.google.code.gson:gson:$gsonVersion") implementation("com.google.guava:guava:$guavaVersion") } diff --git a/client/src/main/java/client/TestClient.java b/client/src/main/java/client/TestClient.java index 75453e4..2fcb9ec 100644 --- a/client/src/main/java/client/TestClient.java +++ b/client/src/main/java/client/TestClient.java @@ -80,7 +80,7 @@ private static void informationThread() { public static void main(String args[]) { - boolean skipGDRP = false; + boolean skipGDRP = true; if (args.length >= 1) { for (String arg : args) { if (arg.equals("--debug")) { diff --git a/client/src/main/java/client/ice/ICEAdapter.java b/client/src/main/java/client/ice/ICEAdapter.java index 0b6d091..f2dafa0 100644 --- a/client/src/main/java/client/ice/ICEAdapter.java +++ b/client/src/main/java/client/ice/ICEAdapter.java @@ -214,15 +214,14 @@ public static void startICEAdapter() { String command[] = new String[]{ // (System.getProperty("os.name").contains("Windows") ? "faf-ice-adapter.exe" : "./faf-ice-adapter"), "java", +// "-Djava.net.preferIPv4Stack=true", "-jar", "faf-ice-adapter.jar", "--id", String.valueOf(TestClient.playerID), "--login", TestClient.username, "--rpc-port", String.valueOf(ADAPTER_PORT), -// "--gpgnet-port", String.valueOf(GPG_PORT), retrieved afterwards -// "--lobby-port", String.valueOf(LOBBY_PORT), + "--game-id", String.valueOf(100), "--log-level", LOG_LEVEL, -// "--log-directory", "iceAdapterLogs/" }; ProcessBuilder processBuilder = new ProcessBuilder(command); diff --git a/ice-adapter/build.gradle b/ice-adapter/build.gradle index 1a7903a..e9e62f4 100644 --- a/ice-adapter/build.gradle +++ b/ice-adapter/build.gradle @@ -10,6 +10,7 @@ apply plugin: 'java' apply plugin: 'java-library' apply plugin: 'com.github.johnrengelman.shadow' apply plugin: 'com.diffplug.spotless' +apply plugin: 'org.openjfx.javafxplugin' group 'com.faforever' @@ -30,15 +31,16 @@ dependencies { annotationProcessor("info.picocli:picocli-codegen:$picocliVersion") implementation("info.picocli:picocli:$picocliVersion") - implementation("org.jitsi:ice4j:3.0-66-g1c60acc") + implementation("org.jitsi:ice4j:3.2-12-gc2cbf61") implementation("com.github.faforever:JJsonRpc:37669e0fed") implementation("com.google.guava:guava:$guavaVersion") - implementation("org.slf4j:slf4j-api:1.7.36") + implementation("org.slf4j:slf4j-api:2.0.13") implementation("ch.qos.logback:logback-classic:$logbackVersion") implementation("ch.qos.logback:logback-core:$logbackVersion") implementation("org.java-websocket:Java-WebSocket:1.5.3") implementation('com.fasterxml.jackson.core:jackson-databind:2.13.4.2') implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.4") + implementation("org.apache.commons:commons-lang3:3.20.0") if(javafxClasspath == "compileOnly") { compileOnly("org.openjfx:javafx-base:${javafxVersion}:${javafxPlatform}") @@ -55,6 +57,10 @@ dependencies { implementation("org.openjfx:javafx-web:${javafxVersion}:${javafxPlatform}") implementation("org.openjfx:javafx-media:${javafxVersion}:${javafxPlatform}") } + + testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.2') + testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.2') + testImplementation('org.junit.jupiter:junit-jupiter-params:5.10.2') } shadowJar { @@ -78,4 +84,8 @@ spotless { cleanthat() palantirJavaFormat() } +} + +test { + useJUnitPlatform() } \ No newline at end of file diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/IceAdapter.java b/ice-adapter/src/main/java/com/faforever/iceadapter/IceAdapter.java index 307bb66..ec961c5 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/IceAdapter.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/IceAdapter.java @@ -1,22 +1,22 @@ package com.faforever.iceadapter; -import static com.faforever.iceadapter.debug.Debug.debug; - import com.faforever.iceadapter.debug.Debug; +import com.faforever.iceadapter.debug.TelemetryDebugger; import com.faforever.iceadapter.gpgnet.GPGNetServer; import com.faforever.iceadapter.gpgnet.GameState; import com.faforever.iceadapter.ice.GameSession; -import com.faforever.iceadapter.ice.PeerIceModule; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; import com.faforever.iceadapter.rpc.RPCService; -import com.faforever.iceadapter.util.ExecutorHolder; -import com.faforever.iceadapter.util.LockUtil; import com.faforever.iceadapter.util.TrayIcon; -import java.util.concurrent.*; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; +import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import picocli.CommandLine; +import java.util.concurrent.Callable; + +import static com.faforever.iceadapter.debug.Debug.debug; + @CommandLine.Command( name = "faf-ice-adapter", mixinStandardHelpOptions = true, @@ -24,18 +24,20 @@ description = "An ice (RFC 5245) based network bridge between FAF client and ForgedAlliance.exe") @Slf4j public class IceAdapter implements Callable, AutoCloseable, FafRpcCallbacks { - private static IceAdapter INSTANCE; + public static volatile IceAdapter INSTANCE; private static String VERSION = "SNAPSHOT"; - private static volatile GameSession GAME_SESSION; @CommandLine.ArgGroup(exclusive = false) + @Getter private IceOptions iceOptions; + @Getter private GPGNetServer gpgNetServer; + @Getter private RPCService rpcService; - - private final ExecutorService executor = ExecutorHolder.getExecutor(); - private static final Lock lockGameSession = new ReentrantLock(); + @Getter + @Setter + private GameSession gameSession; public static void main(String[] args) { new CommandLine(new IceAdapter()).setUnmatchedArgumentsAllowed(true).execute(args); @@ -53,6 +55,14 @@ public void start() { determineVersion(); log.info("Version: {}", VERSION); + gpgNetServer = new GPGNetServer(iceOptions.getGpgnetPort(), iceOptions.getLobbyPort()); + rpcService = new RPCService(iceOptions.getRpcPort()); + gpgNetServer.init(this, rpcService); + rpcService.init(gpgNetServer, this); + + var telemetryDebugger = new TelemetryDebugger(gpgNetServer, iceOptions.getTelemetryServer(), iceOptions.getGameId(), iceOptions.getId()); + Debug.register(telemetryDebugger); + Debug.DELAY_UI_MS = iceOptions.getDelayUi(); Debug.ENABLE_DEBUG_WINDOW = iceOptions.isDebugWindow(); Debug.ENABLE_INFO_WINDOW = iceOptions.isInfoWindow(); @@ -60,14 +70,6 @@ public void start() { TrayIcon.create(); - PeerIceModule.setForceRelay(iceOptions.isForceRelay()); - gpgNetServer = new GPGNetServer(); - rpcService = new RPCService(); - gpgNetServer.init(iceOptions.getGpgnetPort(), iceOptions.getLobbyPort(), rpcService); - rpcService.init(iceOptions.getRpcPort(), gpgNetServer, this); - - PeerIceModule.setForceRelay(iceOptions.isForceRelay()); - PeerIceModule.setRpcService(rpcService); debug().startupComplete(); } @@ -82,9 +84,11 @@ public void onHostGame(String mapName) { @Override public void onJoinGame(String remotePlayerLogin, int remotePlayerId) { log.info("onJoinGame {} {}", remotePlayerId, remotePlayerLogin); - createGameSession(); - int port = GAME_SESSION.connectToPeer(remotePlayerLogin, remotePlayerId, false, 0); + GameSession gs = createGameSession(); + AllowCombination combination = iceOptions.isForceRelay() ? AllowCombination.RELAY : AllowCombination.ALL; + + int port = gs.connectToPeer(remotePlayerLogin, remotePlayerId, false, 0, combination); sendToGpgNet("JoinGame", "127.0.0.1:" + port, remotePlayerLogin, remotePlayerId); } @@ -93,13 +97,28 @@ public void onConnectToPeer(String remotePlayerLogin, int remotePlayerId, boolea if (gpgNetServer.isConnected() && gpgNetServer.getGameState().isPresent() && (gpgNetServer.getGameState().get() == GameState.LAUNCHING - || gpgNetServer.getGameState().get() == GameState.ENDED)) { + || gpgNetServer.getGameState().get() == GameState.ENDED)) { log.warn("Game ended or in progress, ABORTING connectToPeer"); return; } log.info("onConnectToPeer {} {}, offer: {}", remotePlayerId, remotePlayerLogin, offer); - int port = GAME_SESSION.connectToPeer(remotePlayerLogin, remotePlayerId, offer, 0); + + GameSession gs = getGameSession(); + if (gs == null) { + log.warn("onConnectToPeer: no active GAME_SESSION, creating one"); + gs = createGameSession(); + } + + int port; + AllowCombination combination = iceOptions.isForceRelay() ? AllowCombination.RELAY : AllowCombination.ALL; + + try { + port = gs.connectToPeer(remotePlayerLogin, remotePlayerId, offer, 0, combination); + } catch (RuntimeException e) { + log.error("connectToPeer failed for {} {}: {}", remotePlayerId, remotePlayerLogin, e.toString()); + return; + } sendToGpgNet("ConnectToPeer", "127.0.0.1:" + port, remotePlayerLogin, remotePlayerId); } @@ -107,67 +126,96 @@ public void onConnectToPeer(String remotePlayerLogin, int remotePlayerId, boolea @Override public void onDisconnectFromPeer(int remotePlayerId) { log.info("onDisconnectFromPeer {}", remotePlayerId); - GAME_SESSION.disconnectFromPeer(remotePlayerId); + GameSession gs = getGameSessionSafe(); + if (gs != null) { + gs.disconnectFromPeer(remotePlayerId); + } else { + log.warn("onDisconnectFromPeer: GAME_SESSION is null"); + } sendToGpgNet("DisconnectFromPeer", remotePlayerId); } - private static void createGameSession() { - LockUtil.executeWithLock(lockGameSession, () -> { - if (GAME_SESSION != null) { - GAME_SESSION.close(); - GAME_SESSION = null; + private synchronized GameSession createGameSession() { + GameSession gs = gameSession; + if (gs != null) { + try { + gs.close(); + } catch (Exception e) { + log.warn("Error closing previous GAME_SESSION", e); } - - GAME_SESSION = new GameSession(); - }); + } + GameSession gameSession = new GameSession(rpcService, iceOptions); + setGameSession(gameSession); + return gameSession; } /** * Triggered by losing gpgnet connection to FA. * Closes the active Game/ICE session */ - public static void onFAShutdown() { - LockUtil.executeWithLock(lockGameSession, () -> { - if (GAME_SESSION != null) { - log.info("FA SHUTDOWN, closing everything"); - GAME_SESSION.close(); - GAME_SESSION = null; - // Do not put code outside of this if clause, else it will be executed multiple times + public synchronized void onFAShutdown() { + GameSession gs = gameSession; + if (gs != null) { + log.info("FA SHUTDOWN, closing everything"); + try { + gs.close(); + } catch (Exception e) { + log.warn("Error while closing GAME_SESSION during onFAShutdown", e); } - }); + gameSession = null; + } } @Override public void close() { - this.close(0); + close(0); } /** * Stop the ICE adapter */ public static void close(int status) { + IceAdapter instance = INSTANCE; + if (instance == null) { + log.warn("close() called but INSTANCE is null"); + System.exit(status); + return; + } + log.info("close() - stopping the adapter. Status: {}", status); - onFAShutdown(); // will close gameSession aswell - INSTANCE.gpgNetServer.close(); - INSTANCE.rpcService.close(); + instance.onFAShutdown(); // will close gameSession aswell + + try { + instance.gpgNetServer.close(); + } catch (Exception e) { + log.warn("Error closing GPGNetServer", e); + } + try { + instance.rpcService.close(); + } catch (Exception e) { + log.warn("Error closing RPCService", e); + } + Debug.close(); TrayIcon.close(); - INSTANCE.executor.shutdown(); - CompletableFuture.runAsync( - INSTANCE.executor::shutdownNow, CompletableFuture.delayedExecutor(250, TimeUnit.MILLISECONDS)) - .thenRunAsync(() -> System.exit(status), CompletableFuture.delayedExecutor(250, TimeUnit.MILLISECONDS)); + System.exit(status); } @Override public void sendToGpgNet(String header, Object... args) { - gpgNetServer.sendToGpgNet(header, args); + if (gpgNetServer != null) { + gpgNetServer.sendToGpgNet(header, args); + } else { + log.warn("sendToGpgNet called but gpgNetServer is null: {}", header); + } } public static int getId() { - return INSTANCE.iceOptions.getId(); + IceAdapter instance = INSTANCE; + return instance != null && instance.iceOptions != null ? instance.iceOptions.getId() : 0; } public static String getVersion() { @@ -175,31 +223,36 @@ public static String getVersion() { } public static int getGameId() { - return INSTANCE.iceOptions.getGameId(); + IceAdapter instance = INSTANCE; + return instance != null && instance.iceOptions != null ? instance.iceOptions.getGameId() : 0; } public static String getLogin() { - return INSTANCE.iceOptions.getLogin(); + IceAdapter instance = INSTANCE; + return instance != null && instance.iceOptions != null ? instance.iceOptions.getLogin() : ""; } public static String getTelemetryServer() { - return INSTANCE.iceOptions.getTelemetryServer(); + IceAdapter instance = INSTANCE; + return instance != null && instance.iceOptions != null ? instance.iceOptions.getTelemetryServer() : null; } public static int getPingCount() { - return INSTANCE.iceOptions.getPingCount(); + IceAdapter instance = INSTANCE; + return instance != null && instance.iceOptions != null ? instance.iceOptions.getPingCount() : 0; } public static double getAcceptableLatency() { - return INSTANCE.iceOptions.getAcceptableLatency(); - } - - public static Executor getExecutor() { - return INSTANCE.executor; + IceAdapter instance = INSTANCE; + return instance != null && instance.iceOptions != null ? instance.iceOptions.getAcceptableLatency() : Double.MAX_VALUE; } - public static GameSession getGameSession() { - return GAME_SESSION; + public static GameSession getGameSessionSafe() { + IceAdapter instance = INSTANCE; + if (instance == null) { + return null; + } + return instance.gameSession; } private void determineVersion() { diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/IceOptions.java b/ice-adapter/src/main/java/com/faforever/iceadapter/IceOptions.java index 834232f..70244d8 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/IceOptions.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/IceOptions.java @@ -1,11 +1,11 @@ package com.faforever.iceadapter; import lombok.AllArgsConstructor; -import lombok.Getter; +import lombok.Data; import lombok.NoArgsConstructor; import picocli.CommandLine.Option; -@Getter +@Data @NoArgsConstructor @AllArgsConstructor public class IceOptions { @@ -24,8 +24,7 @@ public class IceOptions { @Option(names = "--gpgnet-port", defaultValue = "0", description = "set the port of internal GPGNet server") private int gpgnetPort; - @Option( - names = "--lobby-port", + @Option(names = "--lobby-port", defaultValue = "0", description = "set the port the game lobby should use for incoming UDP packets from the PeerRelay") private int lobbyPort; @@ -39,8 +38,7 @@ public class IceOptions { @Option(names = "--info-window", description = "activate the info window") private boolean infoWindow; - @Option( - names = "--delay-ui", + @Option(names = "--delay-ui", defaultValue = "0", description = "delays the launch of the info and debug window (in ms)") private int delayUi; @@ -51,15 +49,28 @@ public class IceOptions { description = "number of times to ping each turn server to determine latency") private int pingCount; - @Option( - names = "--acceptable-latency", + @Option(names = "--acceptable-latency", defaultValue = "250.0", description = "number of times to ping each turn server to determine latency") private double acceptableLatency; - @Option( - names = "--telemetry-server", + @Option(names = "--telemetry-server", defaultValue = "wss://ice-telemetry.faforever.com", description = "Telemetry server to connect to") private String telemetryServer; + + @Option(names = "--manual-combination-connection", + defaultValue = "false", + description = "Manually editing the connection combination in the UI") + private boolean manualCombinationConnection; + + @Option(names = "--manual-strategy-connection", + defaultValue = "false", + description = "Manually editing the connection strategy in the UI") + private boolean manualStrategyConnection; + + @Option(names = "--additional-info-peer", + defaultValue = "false", + description = "Additional information about Peer in the UI") + private boolean additionalInfoPeer; } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/LogoUtils.java b/ice-adapter/src/main/java/com/faforever/iceadapter/LogoUtils.java new file mode 100644 index 0000000..933cb5c --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/LogoUtils.java @@ -0,0 +1,35 @@ +package com.faforever.iceadapter; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.awt.*; +import java.net.URL; +import java.util.Optional; + +@UtilityClass +@Slf4j +public class LogoUtils { + + private static final String NAME_LOGO = "faf-logo.png"; + + public Image getLogo() { + URL resource = LogoUtils.class.getClassLoader().getResource(NAME_LOGO); + if (resource != null) { + return Toolkit.getDefaultToolkit().createImage(resource); + } else { + log.error("File {} not found", NAME_LOGO); + return null; + } + } + + public Optional getLogoFx() { + URL resource = LogoUtils.class.getClassLoader().getResource(NAME_LOGO); + if (resource != null) { + return Optional.of(new javafx.scene.image.Image(resource.toExternalForm())); + } else { + log.error("File {} not found", NAME_LOGO); + return Optional.empty(); + } + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/UiStarter.java b/ice-adapter/src/main/java/com/faforever/iceadapter/UiStarter.java new file mode 100644 index 0000000..9387455 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/UiStarter.java @@ -0,0 +1,22 @@ +package com.faforever.iceadapter; + +public class UiStarter { + + public static void main(String[] args) { + String[] stubArgs = { + "--id=12345", + "--game-id=67890", + "--login=testUser", + "--gpgnet-port=5000", + "--rpc-port=5001", + "--lobby-port=5002", + "--debug-window=true", + "--info-window=true" + }; + + IceAdapter.main(stubArgs); + + IceAdapter adapter = IceAdapter.INSTANCE; + adapter.onJoinGame("Player2", 123); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debug.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debug.java index 7c2b88d..c3c6221 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debug.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debug.java @@ -1,25 +1,19 @@ package com.faforever.iceadapter.debug; -import com.faforever.iceadapter.IceAdapter; -import java.lang.reflect.InvocationTargetException; -import java.util.concurrent.CompletableFuture; +import com.faforever.iceadapter.ui.IceWindow; +import com.faforever.iceadapter.ui.InfoWindow; +import javafx.application.Platform; import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + @Slf4j public class Debug { - // TODO - public static boolean ENABLE_DEBUG_WINDOW_LOG_TEXT_AREA = - false; // disabled as this causes high memory and cpu load, should be replaced by limiting the number of - // lines in the text area - public static boolean ENABLE_DEBUG_WINDOW = false; public static boolean ENABLE_INFO_WINDOW = false; public static int DELAY_UI_MS = 0; // delays the launch of the user interface by X ms - public static int RPC_PORT; - - private static TelemetryDebugger telemetryDebugger; - private static final DebugFacade debugFacade = new DebugFacade(); public static void register(Debugger debugger) { @@ -31,46 +25,36 @@ public static void remove(Debugger debugger) { } public static void init() { - telemetryDebugger = - new TelemetryDebugger(IceAdapter.getTelemetryServer(), IceAdapter.getGameId(), IceAdapter.getId()); + if (isJavaFxSupported()) { + CompletableFuture.runAsync(IceWindow::launch); - // Debugger window is started and set to debugFuture when either window is requested as the info window can be - // used to open the debug window - // This is not used anymore as the debug window is started and hidden in case it is requested via the tray icon - if (!ENABLE_DEBUG_WINDOW && !ENABLE_INFO_WINDOW) { - return; - } + if (Debug.ENABLE_INFO_WINDOW) { + CompletableFuture.runAsync( + () -> runOnUIThread(InfoWindow::launch), + CompletableFuture.delayedExecutor(Debug.DELAY_UI_MS, TimeUnit.MILLISECONDS)); + } - if (isJavaFxSupported()) { - CompletableFuture.runAsync( - () -> { - try { - Class.forName("com.faforever.iceadapter.debug.DebugWindow") - .getMethod("launchApplication") - .invoke(null); - } catch (InvocationTargetException e) { - log.info("DebugWindows stopped"); - } catch (IllegalAccessException | ClassNotFoundException | NoSuchMethodException e) { - log.error("Could not create DebugWindow. Running without debug window.", e); - } - }, - IceAdapter.getExecutor()); } else { log.info("No JavaFX support detected. Running without debug window."); } } public static void close() { - if (telemetryDebugger != null) { - telemetryDebugger.close(); - telemetryDebugger = null; - } + debugFacade.close(); } public static Debugger debug() { return debugFacade; } + private static void runOnUIThread(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } + public static boolean isJavaFxSupported() { try { Debug.class.getClassLoader().loadClass("javafx.application.Application"); diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugFacade.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugFacade.java index d7a60eb..165dc29 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugFacade.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugFacade.java @@ -1,8 +1,9 @@ package com.faforever.iceadapter.debug; -import com.faforever.iceadapter.ice.Peer; +import com.faforever.iceadapter.ice.peer.Peer; import com.faforever.iceadapter.telemetry.CoturnServer; import com.nbarraille.jjsonrpc.JJsonPeer; + import java.util.Collection; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -68,4 +69,9 @@ public void peerConnectivityUpdate(Peer peer) { public void updateCoturnList(Collection servers) { debuggers.forEach(d -> d.updateCoturnList(servers)); } + + @Override + public void close() { + debuggers.forEach(Debugger::close); + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindow.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindow.java deleted file mode 100644 index 3286826..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindow.java +++ /dev/null @@ -1,379 +0,0 @@ -package com.faforever.iceadapter.debug; - -import com.faforever.iceadapter.IceAdapter; -import com.faforever.iceadapter.gpgnet.GPGNetServer; -import com.faforever.iceadapter.gpgnet.GameState; -import com.faforever.iceadapter.ice.Peer; -import com.faforever.iceadapter.ice.PeerConnectivityCheckerModule; -import com.nbarraille.jjsonrpc.JJsonPeer; -import java.io.IOException; -import java.util.Comparator; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import javafx.application.Application; -import javafx.application.Platform; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleIntegerProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; -import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; -import javafx.scene.Scene; -import javafx.scene.image.Image; -import javafx.stage.Stage; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.ice4j.ice.Candidate; -import org.ice4j.ice.CandidatePair; -import org.ice4j.ice.CandidateType; -import org.ice4j.ice.Component; -import org.jetbrains.annotations.NotNull; - -@Slf4j -public class DebugWindow extends Application implements Debugger { - public static CompletableFuture INSTANCE = new CompletableFuture<>(); - - private Parent root; - private Scene scene; - private DebugWindowController controller; - private Stage stage; - - private static final int WIDTH = 1200; - private static final int HEIGHT = 700; - - private final ObservableList peers = FXCollections.observableArrayList(); - - @Override - public void start(Stage stage) { - INSTANCE = CompletableFuture.completedFuture(this); - Debug.register(this); - - this.stage = stage; - stage.getIcons().add(new Image("https://faforever.com/images/faf-logo.png")); - - try { - FXMLLoader loader = new FXMLLoader(getClass().getResource("/debugWindow.fxml")); - root = loader.load(); - - controller = loader.getController(); - controller.peerTable.setItems(peers); - - } catch (IOException e) { - log.error("Could not load debugger window fxml", e); - } - - setUserAgentStylesheet(STYLESHEET_MODENA); - - scene = new Scene(root, WIDTH, HEIGHT); - - stage.setScene(scene); - stage.setTitle("FAF ICE adapter - Debugger - Build: %s".formatted(IceAdapter.getVersion())); - - if (Debug.ENABLE_DEBUG_WINDOW) { - CompletableFuture.runAsync( - () -> runOnUIThread(stage::show), - CompletableFuture.delayedExecutor( - Debug.DELAY_UI_MS, TimeUnit.MILLISECONDS, IceAdapter.getExecutor())); - } - - log.info("Created debug window."); - - if (Debug.ENABLE_INFO_WINDOW) { - CompletableFuture.runAsync( - () -> runOnUIThread(() -> new InfoWindow().init()), - CompletableFuture.delayedExecutor( - Debug.DELAY_UI_MS, TimeUnit.MILLISECONDS, IceAdapter.getExecutor())); - } - } - - public void showWindow() { - runOnUIThread(() -> { - stage.show(); - initStaticVariables(); - initPeers(); - }); - } - - @Override - public void startupComplete() { - initStaticVariables(); - } - - public void initStaticVariables() { - runOnUIThread(() -> { - controller.versionLabel.setText("Version: %s".formatted(IceAdapter.getVersion())); - controller.userLabel.setText("User: %s(%d)".formatted(IceAdapter.getLogin(), IceAdapter.getId())); - controller.rpcPortLabel.setText("RPC_PORT: %d".formatted(Debug.RPC_PORT)); - controller.gpgnetPortLabel.setText("GPGNET_PORT: %d".formatted(GPGNetServer.getGpgnetPort())); - controller.lobbyPortLabel.setText("LOBBY_PORT: %d".formatted(GPGNetServer.getLobbyPort())); - }); - } - - public void initPeers() { - runOnUIThread(() -> { - synchronized (peers) { - peers.clear(); - for (Peer peer : IceAdapter.getGameSession().getPeers().values()) { - DebugPeer p = new DebugPeer(peer); - p.stateChangedUpdate(peer); - p.connectivityUpdate(peer); - peers.add(p); - } - peers.sort(DebugPeer::compareTo); - } - }); - } - - @Override - public void rpcStarted(CompletableFuture peerFuture) { - runOnUIThread(() -> { - controller.rpcServerStatus.setText("RPCServer: started"); - }); - peerFuture.thenAccept(peer -> runOnUIThread(() -> { - controller.rpcClientStatus.setText( - "RPCClient: %s".formatted(peer.getSocket().getInetAddress())); - })); - } - - @Override - public void gpgnetStarted() { - runOnUIThread(() -> { - controller.gpgnetServerStatus.setText("GPGNetServer: started"); - }); - } - - @Override - public void gpgnetConnectedDisconnected() { - runOnUIThread(() -> { - controller.gpgnetServerStatus.setText( - "GPGNetClient: %s".formatted(GPGNetServer.isConnected() ? "connected" : "-")); - gameStateChanged(); - }); - } - - @Override - public void gameStateChanged() { - runOnUIThread(() -> { - controller.gameState.setText(String.format( - "GameState: %s", - GPGNetServer.getGameState().map(GameState::getName).orElse("-"))); - }); - } - - @Override - public void connectToPeer(int id, String login, boolean localOffer) { - runOnUIThread(() -> { - synchronized (peers) { - peers.add(new DebugPeer(id, login, localOffer)); // Might callback into jfx - peers.sort(DebugPeer::compareTo); - } - }); - } - - @Override - public void disconnectFromPeer(int id) { - runOnUIThread(() -> { - synchronized (peers) { - peers.removeIf(peer -> peer.id.get() == id); // Might callback into jfx - peers.sort(DebugPeer::compareTo); - } - }); - } - - @Override - public void peerStateChanged(Peer peer) { - runOnUIThread(() -> { - synchronized (peers) { - peers.stream().filter(p -> p.id.get() == peer.getRemoteId()).forEach(p -> { - p.stateChangedUpdate(peer); - }); - } - }); - } - - @Override - public void peerConnectivityUpdate(Peer peer) { - runOnUIThread(() -> { - synchronized (peers) { - peers.stream().filter(p -> p.id.get() == peer.getRemoteId()).forEach(p -> { - p.connectivityUpdate(peer); - }); - } - }); - } - - private void runOnUIThread(Runnable runnable) { - if (Platform.isFxApplicationThread()) { - runnable.run(); - } else { - Platform.runLater(runnable); - } - } - - public static void launchApplication() { - launch(DebugWindow.class, null); - } - - @NoArgsConstructor - @AllArgsConstructor - // @Getter //PropertyValueFactory will attempt to access fieldNameProperty(), then getFieldName() (expecting value, - // not property) and then isFieldName() methods - public static class DebugPeer implements Comparable { - public SimpleIntegerProperty id = new SimpleIntegerProperty(-1); - public SimpleStringProperty login = new SimpleStringProperty(""); - public SimpleBooleanProperty localOffer = new SimpleBooleanProperty(false); - public SimpleBooleanProperty connected = new SimpleBooleanProperty(false); - public SimpleStringProperty state = new SimpleStringProperty(""); - public SimpleIntegerProperty averageRtt = new SimpleIntegerProperty(-1); - public SimpleIntegerProperty lastReceived = new SimpleIntegerProperty(-1); - public SimpleIntegerProperty echosReceived = new SimpleIntegerProperty(-1); - public SimpleIntegerProperty invalidEchosReceived = new SimpleIntegerProperty(-1); - public SimpleStringProperty localCandidate = new SimpleStringProperty(""); - public SimpleStringProperty remoteCandidate = new SimpleStringProperty(""); - - public DebugPeer(Peer peer) { - this(peer.getRemoteId(), peer.getRemoteLogin(), peer.isLocalOffer()); - } - - public DebugPeer(int id, String login, boolean localOffer) { - this.id.set(id); - this.login.set(login); - this.localOffer.set(localOffer); - } - - public int getId() { - return id.get(); - } - - public SimpleIntegerProperty idProperty() { - return id; - } - - public String getLogin() { - return login.get(); - } - - public SimpleStringProperty loginProperty() { - return login; - } - - public boolean isLocalOffer() { - return localOffer.get(); - } - - public SimpleBooleanProperty localOfferProperty() { - return localOffer; - } - - public boolean isConnected() { - return connected.get(); - } - - public SimpleBooleanProperty connectedProperty() { - return connected; - } - - public String getState() { - return state.get(); - } - - public SimpleStringProperty stateProperty() { - return state; - } - - public double getAverageRtt() { - return averageRtt.get(); - } - - public SimpleIntegerProperty averageRttProperty() { - return averageRtt; - } - - public int getLastReceived() { - return lastReceived.get(); - } - - public SimpleIntegerProperty lastReceivedProperty() { - return lastReceived; - } - - public int getEchosReceived() { - return echosReceived.get(); - } - - public int getInvalidEchosReceived() { - return invalidEchosReceived.get(); - } - - public SimpleIntegerProperty echosReceivedProperty() { - return echosReceived; - } - - public SimpleIntegerProperty invalidEchosReceivedProperty() { - return invalidEchosReceived; - } - - public String getLocalCandidate() { - return localCandidate.get(); - } - - public SimpleStringProperty localCandidateProperty() { - return localCandidate; - } - - public String getRemoteCandidate() { - return remoteCandidate.get(); - } - - public SimpleStringProperty remoteCandidateProperty() { - return remoteCandidate; - } - - public void stateChangedUpdate(Peer peer) { - connected.set(peer.getIce().isConnected()); - state.set(peer.getIce().getIceState().getMessage()); - localCandidate.set(Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getLocalCandidate) - .map(Candidate::getType) - .map(CandidateType::toString) - .orElse("")); - remoteCandidate.set(Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getRemoteCandidate) - .map(Candidate::getType) - .map(CandidateType::toString) - .orElse("")); - } - - public void connectivityUpdate(Peer peer) { - Optional connectivityChecker = - Optional.ofNullable(peer.getIce().getConnectivityChecker()); - averageRtt.set(connectivityChecker - .map(PeerConnectivityCheckerModule::getAverageRTT) - .orElse(-1.0f) - .intValue()); - lastReceived.set(connectivityChecker - .map(PeerConnectivityCheckerModule::getLastPacketReceived) - .map(last -> System.currentTimeMillis() - last) - .orElse(-1L) - .intValue()); - echosReceived.set(connectivityChecker - .map(PeerConnectivityCheckerModule::getEchosReceived) - .orElse(-1L) - .intValue()); - echosReceived.set(connectivityChecker - .map(PeerConnectivityCheckerModule::getEchosReceived) - .orElse(-1L) - .intValue()); - } - - @Override - public int compareTo(@NotNull DebugPeer o) { - return Comparator.comparingLong(DebugPeer::getId).compare(this, o); - } - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindowController.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindowController.java deleted file mode 100644 index fd8623b..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/DebugWindowController.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.faforever.iceadapter.debug; - -import com.faforever.iceadapter.IceAdapter; -import java.util.concurrent.CompletableFuture; -import javafx.event.ActionEvent; -import javafx.fxml.FXML; -import javafx.scene.control.*; -import javafx.scene.control.cell.PropertyValueFactory; -import javafx.scene.layout.HBox; -import javafx.util.Callback; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.LoggerFactory; - -@Slf4j -public class DebugWindowController { - public static DebugWindowController INSTANCE; - - public HBox genericInfo; - public Label versionLabel; - public Label userLabel; - public Label rpcPortLabel; - public Label gpgnetPortLabel; - public Label lobbyPortLabel; - public TextArea logTextArea; - public HBox rpcGpgInfo; - public HBox gpgnetInfo; - public Label rpcServerStatus; - public Label rpcClientStatus; - public HBox rpcInfo; - public Label gpgnetServerStatus; - public Label gpgnetClientStatus; - public Label gameState; - public TableView peerTable; - public TableColumn idColumn; - public TableColumn loginColumn; - public TableColumn offerColumn; - public TableColumn connectedColumn; - public TableColumn buttonReconnect; - public TableColumn stateColumn; - public TableColumn rttColumn; - public TableColumn lastColumn; - public TableColumn echosRcvColumn; - public TableColumn invalidEchosRcvColumn; - public TableColumn localCandColumn; - public TableColumn remoteCandColumn; - - public Button killAdapterButton; - - public DebugWindowController() {} - - public void onKillAdapterClicked(ActionEvent actionEvent) { - IceAdapter.close(337); - } - - public void reconnectToPeer(DebugWindow.DebugPeer peer) { - if (peer != null) { - CompletableFuture.runAsync( - () -> IceAdapter.getGameSession().reconnectToPeer(peer.getId()), IceAdapter.getExecutor()); - } - } - - @FXML - private void initialize() { - if (Debug.ENABLE_DEBUG_WINDOW_LOG_TEXT_AREA) { - ((TextAreaLogAppender) - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)) - .getAppender("TEXTAREA")) - .setTextArea(logTextArea); - } - - logTextArea - .textProperty() - .addListener((observableValue, oldVal, newVal) -> logTextArea.setScrollTop(Double.MAX_VALUE)); - - idColumn.setCellValueFactory(new PropertyValueFactory<>("id")); - loginColumn.setCellValueFactory(new PropertyValueFactory<>("login")); - offerColumn.setCellValueFactory(new PropertyValueFactory<>("localOffer")); - connectedColumn.setCellValueFactory(new PropertyValueFactory<>("connected")); - stateColumn.setCellValueFactory(new PropertyValueFactory<>("state")); - rttColumn.setCellValueFactory(new PropertyValueFactory<>("averageRtt")); - lastColumn.setCellValueFactory(new PropertyValueFactory<>("lastReceived")); - echosRcvColumn.setCellValueFactory(new PropertyValueFactory<>("echosReceived")); - invalidEchosRcvColumn.setCellValueFactory(new PropertyValueFactory<>("invalidEchosReceived")); - localCandColumn.setCellValueFactory(new PropertyValueFactory<>("localCandidate")); - remoteCandColumn.setCellValueFactory(new PropertyValueFactory<>("remoteCandidate")); - - buttonReconnect.setCellFactory(new Callback() { - @Override - public TableCell call(TableColumn param) { - return new TableCell<>() { - final Button btn = new Button("reconnect"); - - @Override - protected void updateItem(DebugWindow.DebugPeer item, boolean empty) { - super.updateItem(item, empty); - setText(null); - if (empty) { - setGraphic(null); - } else { - btn.setOnAction(event -> { - DebugWindow.DebugPeer peer = getTableRow().getItem(); - reconnectToPeer(peer); - }); - setGraphic(btn); - } - } - }; - } - }); - - killAdapterButton.setOnAction(this::onKillAdapterClicked); - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debugger.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debugger.java index e2e6edc..735ba95 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debugger.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/Debugger.java @@ -1,8 +1,9 @@ package com.faforever.iceadapter.debug; -import com.faforever.iceadapter.ice.Peer; +import com.faforever.iceadapter.ice.peer.Peer; import com.faforever.iceadapter.telemetry.CoturnServer; import com.nbarraille.jjsonrpc.JJsonPeer; + import java.util.Collection; import java.util.concurrent.CompletableFuture; @@ -27,4 +28,7 @@ public interface Debugger { void peerConnectivityUpdate(Peer peer); default void updateCoturnList(Collection servers) {} + + default void close() { + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TelemetryDebugger.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TelemetryDebugger.java index c604600..af1a58f 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TelemetryDebugger.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TelemetryDebugger.java @@ -2,129 +2,176 @@ import com.faforever.iceadapter.IceAdapter; import com.faforever.iceadapter.gpgnet.GPGNetServer; -import com.faforever.iceadapter.ice.Peer; -import com.faforever.iceadapter.ice.PeerConnectivityCheckerModule; +import com.faforever.iceadapter.ice.peer.Peer; import com.faforever.iceadapter.telemetry.*; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.common.util.concurrent.RateLimiter; import com.nbarraille.jjsonrpc.JJsonPeer; +import lombok.EqualsAndHashCode; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Candidate; +import org.ice4j.ice.CandidatePair; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.exceptions.WebsocketNotConnectedException; +import org.java_websocket.handshake.ServerHandshake; + import java.net.ConnectException; import java.net.URI; +import java.time.Duration; import java.time.Instant; import java.util.Collection; import java.util.Map; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.LinkedBlockingQueue; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import org.ice4j.ice.Candidate; -import org.ice4j.ice.CandidatePair; -import org.ice4j.ice.Component; -import org.java_websocket.client.WebSocketClient; -import org.java_websocket.handshake.ServerHandshake; +import java.util.concurrent.*; @Slf4j +@EqualsAndHashCode public class TelemetryDebugger implements Debugger, AutoCloseable { - private final WebSocketClient websocketClient; + private static final int MAX_RECONNECT_ATTEMPTS = 5; + private static final Duration RECONNECT_BASE_DELAY = Duration.ofSeconds(1); + private static final Duration MAX_RECONNECT_DELAY = Duration.ofSeconds(30); + + private final GPGNetServer gpgNetServer; + private final URI websocketUri; + private volatile WebSocketClient websocketClient; private final ObjectMapper objectMapper; private final Map peerRateLimiter = new ConcurrentHashMap<>(); - private final BlockingQueue messageQueue = new LinkedBlockingQueue<>(); + private final BlockingQueue messageQueue = new LinkedBlockingQueue<>(1000); private final Thread sendingLoopThread; - public TelemetryDebugger(String telemetryServer, int gameId, int playerId) { - Debug.register(this); + private volatile boolean shouldRun = true; + private int reconnectAttempt = 0; - URI uri = URI.create("%s/adapter/v1/game/%d/player/%d".formatted(telemetryServer, gameId, playerId)); + public TelemetryDebugger(GPGNetServer gpgNetServer, String telemetryServer, int gameId, int playerId) { + this.gpgNetServer = gpgNetServer; + websocketUri = URI.create("%s/adapter/v1/game/%d/player/%d".formatted(telemetryServer, gameId, playerId)); log.info( "Open the telemetry ui via {}/app.html?gameId={}&playerId={}", telemetryServer.replaceFirst("ws", "http"), gameId, playerId); - websocketClient = new WebSocketClient(uri) { + createNewWebSocketClient(); + + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + sendingLoopThread = Thread.ofVirtual().name("telemetry-sending-loop").start(this::sendingLoop); + } + + private void createNewWebSocketClient() { + this.websocketClient = new WebSocketClient(websocketUri) { @Override public void onOpen(ServerHandshake handshakedata) { - log.info("Telemetry websocket opened"); + log.trace("Telemetry websocket opened"); } @Override public void onMessage(String message) { - log.info("Telemetry websocket message: {}", message); + log.debug("Telemetry websocket message: {}", message); } @Override public void onClose(int code, String reason, boolean remote) { - log.info("Telemetry websocket closed (reason: {})", reason); + log.info("Telemetry websocket closed (code: {}, reason: {})", code, reason); } @Override public void onError(Exception ex) { if (ex instanceof ConnectException) { - log.error("Error connecting to Telemetry websocket", ex); - Debug.remove(TelemetryDebugger.this); + log.warn("Failed to connect to telemetry server", ex); } else { - log.error("Error in Telemetry websocket", ex); + log.error("Telemetry websocket error", ex); } } }; - - objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - - sendingLoopThread = Thread.ofVirtual().name("sendingLoop").start(this::sendingLoop); } private void sendMessage(OutgoingMessageV1 message) { - try { - messageQueue.put(message); - } catch (InterruptedException e) { - throw new RuntimeException(e); + if (!messageQueue.offer(message)) { + log.warn("Telemetry message queue is full. Dropping message: {}", message.getType()); } } @SneakyThrows private void sendingLoop() { - while (!Thread.currentThread().isInterrupted()) { - var message = messageQueue.take(); - try { - String json = objectMapper.writeValueAsString(message); + try { + while (shouldRun) { + OutgoingMessageV1 message = messageQueue.take(); - if (websocketClient.isClosed()) { - log.warn("Telemetry websocket is closed"); - websocketClient.reconnectBlocking(); - log.info("Telemetry websocket reconnected"); + if (!ensureConnected()) { + log.warn("Failed to send telemetry message (no connection): {}", message.getType()); + continue; } - log.trace("Sending telemetry message: {}", json); - websocketClient.send(json); - } catch (InterruptedException e) { - log.info("Sending loop interrupted"); - return; - } catch (Exception e) { - log.error("Error on sending message object: {}", message, e); + try { + String json = objectMapper.writeValueAsString(message); + websocketClient.send(json); + log.trace("Sent telemetry message: {}", json); + } catch (WebsocketNotConnectedException e) { + log.warn("Telemetry websocket not connected: {}", message.getType()); + } catch (Exception e) { + log.error("Failed to serialize or send telemetry message: {}", message, e); + } } + } catch (InterruptedException e) { + // Restore interrupt and exit cleanly + Thread.currentThread().interrupt(); + log.debug("Telemetry sending loop interrupted, shutting down."); + } catch (Exception e) { + log.error("Unexpected error in telemetry sending loop", e); + } finally { + close(); + log.debug("Telemetry sending loop terminated."); } } - @Override - public void startupComplete() { - try { - if (!websocketClient.connectBlocking()) { + private boolean ensureConnected() throws InterruptedException { + while (shouldRun && !websocketClient.isOpen()) { + if (!shouldRun) { + return false; + } + + // Exponential latency with jitter + Duration delay = RECONNECT_BASE_DELAY.multipliedBy((long) Math.pow(2, Math.min(reconnectAttempt, 5))); + delay = delay.plusMillis(ThreadLocalRandom.current().nextLong(0, 1000)); + delay = Duration.ofMillis(Math.min(delay.toMillis(), MAX_RECONNECT_DELAY.toMillis())); + + log.info("Attempting to connect to telemetry server... Attempt {}/{}", reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS); + + Thread.sleep(delay.toMillis()); + + try { + createNewWebSocketClient(); + if (websocketClient.connectBlocking()) { + reconnectAttempt = 0; + return true; + } else { + log.warn("Failed to connect to telemetry websocket (attempt {}/{})", reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS); + } + } catch (Exception e) { + log.warn("Exception during connect attempt {}/{}", reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS, e); + } + + reconnectAttempt++; + + if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { + log.error("Max reconnect attempts reached. Stopping telemetry debugger."); + shouldRun = false; Debug.remove(this); - return; + return false; } - } catch (InterruptedException e) { - Debug.remove(this); - log.error("Failed to connect to telemetry websocket", e); } + return true; + } + @Override + public void startupComplete() { sendMessage(new RegisterAsPeer( UUID.randomUUID(), "java-ice-adapter/" + IceAdapter.getVersion(), IceAdapter.getLogin())); } @@ -143,7 +190,7 @@ public void gpgnetStarted() { @Override public void gpgnetConnectedDisconnected() { sendMessage(new UpdateGpgnetState( - UUID.randomUUID(), GPGNetServer.isConnected() ? "GAME_CONNECTED" : "WAITING_FOR_GAME")); + UUID.randomUUID(), gpgNetServer.isConnected() ? "GAME_CONNECTED" : "WAITING_FOR_GAME")); } @Override @@ -167,18 +214,15 @@ public void disconnectFromPeer(int id) { @Override public void peerStateChanged(Peer peer) { + Optional pair = peer.getActiveCandidatePair(); sendMessage(new UpdatePeerState( UUID.randomUUID(), peer.getRemoteId(), - peer.getIce().getIceState(), - Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getLocalCandidate) + peer.getIceState(), + pair.map(CandidatePair::getLocalCandidate) .map(Candidate::getType) .orElse(null), - Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getRemoteCandidate) + pair.map(CandidatePair::getRemoteCandidate) .map(Candidate::getType) .orElse(null))); } @@ -201,11 +245,8 @@ public void peerConnectivityUpdate(Peer peer) { sendMessage(new UpdatePeerConnectivity( UUID.randomUUID(), peer.getRemoteId(), - Optional.ofNullable(peer.getIce().getConnectivityChecker()) - .map(PeerConnectivityCheckerModule::getAverageRTT) - .orElse(null), - Optional.ofNullable(peer.getIce().getConnectivityChecker()) - .map(PeerConnectivityCheckerModule::getLastPacketReceived) + peer.getRtt(), + peer.getLastReceived() .map(Instant::ofEpochMilli) .orElse(null))); } @@ -220,6 +261,15 @@ public void updateCoturnList(Collection servers) { @Override public void close() { + shouldRun = false; + if (websocketClient != null) { + websocketClient.close(); + } sendingLoopThread.interrupt(); + try { + sendingLoopThread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TextAreaLogAppender.java b/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TextAreaLogAppender.java deleted file mode 100644 index 7cfdb28..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/TextAreaLogAppender.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.faforever.iceadapter.debug; - -import ch.qos.logback.core.OutputStreamAppender; -import java.io.FilterOutputStream; -import java.io.OutputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.List; -import javafx.application.Platform; - -public class TextAreaLogAppender extends OutputStreamAppender { - - private TextAreaOutputStream textAreaOutputStream = new TextAreaOutputStream(); - - public TextAreaLogAppender() {} - - public void setTextArea(Object textArea) { - textAreaOutputStream.setTextArea(textArea); - } - - @Override - public void start() { - setOutputStream(new FilterOutputStream(textAreaOutputStream)); - super.start(); - } - - private static class TextAreaOutputStream extends OutputStream { - - private Object textArea; - private Method textAreaAppendMethod; - private List buffer = new ArrayList<>(); - - public TextAreaOutputStream() {} - - @Override - public void write(int b) { - if (DebugWindow.INSTANCE.isDone()) { - if (!buffer.isEmpty()) { - buffer.clear(); - } - } - - if (textArea != null) { - while (!buffer.isEmpty()) { - appendText(String.valueOf((char) buffer.remove(0).intValue())); - } - appendText(String.valueOf((char) b)); - } else { - buffer.add(b); - } - } - - private void appendText(String text) { - Platform.runLater(() -> { - try { - textAreaAppendMethod.invoke(textArea, text); - } catch (IllegalAccessException | InvocationTargetException e) { - e.printStackTrace(); - throw new RuntimeException("Could not append log to textArea"); - } - }); - } - - public void setTextArea(Object textArea) { - if (!textArea.getClass().getCanonicalName().equals("javafx.scene.control.TextArea")) { - throw new RuntimeException("Object is of class %s, expected javafx.scene.control.TextArea" - .formatted(textArea.getClass().getCanonicalName())); - } - this.textArea = textArea; - try { - this.textAreaAppendMethod = textArea.getClass().getMethod("appendText", String.class); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - throw new RuntimeException( - "Could not instantiate TextAreaLogAppender, could not find TextArea appendText method"); - } - } - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/dto/IceServerView.java b/ice-adapter/src/main/java/com/faforever/iceadapter/dto/IceServerView.java new file mode 100644 index 0000000..8b491c7 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/dto/IceServerView.java @@ -0,0 +1,27 @@ +package com.faforever.iceadapter.dto; + +import com.faforever.iceadapter.ice.IceServer; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import lombok.Data; + +@Data +public class IceServerView { + private final IceServer server; + private final StringProperty type; + private final StringProperty transport; + private final StringProperty address; + private final StringProperty rtt; + private final BooleanProperty enabled; + + public IceServerView(IceServer server) { + this.server = server; + this.type = new SimpleStringProperty(server.getType().name()); + this.transport = new SimpleStringProperty(server.getAddress().getTransport().name()); + this.address = new SimpleStringProperty(server.getAddress().getHostName() + ":" + server.getAddress().getPort()); + this.rtt = new SimpleStringProperty(server.strTripTime()); + this.enabled = new SimpleBooleanProperty(server.isEnabled()); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/dto/PeerView.java b/ice-adapter/src/main/java/com/faforever/iceadapter/dto/PeerView.java new file mode 100644 index 0000000..760f575 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/dto/PeerView.java @@ -0,0 +1,103 @@ +package com.faforever.iceadapter.dto; + +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.peer.IceAgentStrategy; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import javafx.beans.property.*; +import lombok.Data; +import org.ice4j.ice.Agent; + +import java.util.Objects; +import java.util.function.Supplier; + +@Data +public class PeerView implements PeerEventListener { + private final IntegerProperty id = new SimpleIntegerProperty(); + private final StringProperty login = new SimpleStringProperty(); + private final BooleanProperty connected = new SimpleBooleanProperty(); + private final StringProperty localCand = new SimpleStringProperty(); + private final StringProperty remoteCand = new SimpleStringProperty(); + private final StringProperty pairConnection = new SimpleStringProperty(); + private final StringProperty state = new SimpleStringProperty(); + private final StringProperty agent = new SimpleStringProperty(); + private final StringProperty offer = new SimpleStringProperty(); + private final StringProperty rtt = new SimpleStringProperty(); + private final StringProperty lastRecv = new SimpleStringProperty(); + private final StringProperty echosReceived = new SimpleStringProperty(); + private final AdditionalInfo additionalInfo = new AdditionalInfo(); + + @Data + public static class AdditionalInfo { + private final BooleanProperty allowHost = new SimpleBooleanProperty(); + private final BooleanProperty allowReflexive = new SimpleBooleanProperty(); + private final BooleanProperty allowRelay = new SimpleBooleanProperty(); + private AllowCombination combination; + private IceAgentStrategy agentStrategy; + private Supplier getFullCandidateInfo; + } + + public PeerView(int id, String login) { + this.id.set(id); + this.login.set(login); + } + + @Override + public void onIceStateChange(Peer peer, IceState oldState, IceState newState) { + update(peer); + } + + @Override + public void onAgentChange(Peer peer, Agent agent) { + update(peer); + } + + @Override + public void onLastPacketReceived(Peer peer, Long lastTimestamp, Long timestamp) { + update(peer); + } + + public void update(Peer peer) { + getConnected().set(peer.isConnected()); + + getPairConnection().set(peer.getStrCandidateTypes("\n")); + + getState().set(String.valueOf(peer.getState())); + getAgent().set(peer.getAgentState() + .map(String::valueOf) + .orElse("-")); + + getOffer().set(String.valueOf(peer.isLocalOffer())); + getRtt().set(peer.getAverageRtt() + .map(Math::round) + .map(String::valueOf) + .orElse("–")); + getLastRecv().set(peer.getLastReceived() + .map(ts -> "%.1fs ago".formatted((System.currentTimeMillis() - ts) / 1000f)) + .orElse("never")); + getEchosReceived().set("%s/%s".formatted(String.valueOf(peer.countEchosReceived()), String.valueOf(peer.countInvalidEchosReceived()))); + + AllowCombination combination = peer.getCombination(); + getAdditionalInfo().getAllowHost().set(combination.isAllowHost()); + getAdditionalInfo().getAllowReflexive().set(combination.isAllowReflexive()); + getAdditionalInfo().getAllowRelay().set(combination.isAllowRelay()); + getAdditionalInfo().setAgentStrategy(peer.getAgentStrategy()); + getAdditionalInfo().setCombination(combination); + + getAdditionalInfo().setGetFullCandidateInfo(peer::getFullInfoSelectedPair); + } + + @Override + public boolean equals(Object object) { + if (object == null || getClass() != object.getClass()) return false; + PeerView peerView = (PeerView) object; + return Objects.equals(id.get(), peerView.id.get()); + } + + @Override + public int hashCode() { + return Objects.hashCode(id.get()); + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataInputStream.java b/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataInputStream.java index 4809b23..4302847 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataInputStream.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataInputStream.java @@ -1,6 +1,7 @@ package com.faforever.iceadapter.gpgnet; import com.google.common.io.LittleEndianDataInputStream; + import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; @@ -16,17 +17,31 @@ public class FaDataInputStream extends InputStream { private static final int MAX_CHUNK_SIZE = 10; private static final int FIELD_TYPE_INT = 0; + private static final int FIELD_TYPE_STRING = 1; private final LittleEndianDataInputStream inputStream; private final Charset charset = StandardCharsets.UTF_8; public FaDataInputStream(InputStream inputStream) { + if (inputStream == null) { + throw new IllegalArgumentException("Input stream cannot be null"); + } this.inputStream = new LittleEndianDataInputStream(new BufferedInputStream(inputStream)); } + /** + * Читает блоки данных из FA. Поддерживает только int и строковые значения. + * + * @return список объектов (Integer или String) + * @throws IOException при ошибках чтения или некорректных данных + */ public List readChunks() throws IOException { int numberOfChunks = readInt(); + if (numberOfChunks < 0) { + throw new IOException("Invalid chunk count: " + numberOfChunks); + } + if (numberOfChunks > MAX_CHUNK_SIZE) { throw new IOException("Too many chunks: " + numberOfChunks); } @@ -41,9 +56,14 @@ public List readChunks() throws IOException { chunks.add(readInt()); break; + case FIELD_TYPE_STRING: + String str = readString(); + str = str.replace("\\t", "\t").replace("\\n", "\n"); + chunks.add(str); + break; + default: - // This could surely be optimized - chunks.add(readString().replace("/t", "\t").replace("/n", "\n")); + throw new IOException("Unknown field type: " + fieldType); } } @@ -59,9 +79,20 @@ public int read() throws IOException { return inputStream.read(); } + /** + * Читает строку с префиксом длины (int). + */ public String readString() throws IOException { int size = readInt(); + if (size < 0) { + throw new IOException("Invalid string length: " + size); + } + + if (size == 0) { + return ""; + } + byte[] buffer = new byte[size]; inputStream.readFully(buffer); return new String(buffer, charset); diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataOutputStream.java b/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataOutputStream.java index 08a3442..8580e35 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataOutputStream.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/FaDataOutputStream.java @@ -1,6 +1,8 @@ package com.faforever.iceadapter.gpgnet; import com.google.common.io.LittleEndianDataOutputStream; +import lombok.extern.slf4j.Slf4j; + import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -14,22 +16,29 @@ /** * Writes data to Forged Alliance (the forgedalliance, not the lobby). */ +@Slf4j public class FaDataOutputStream extends OutputStream { public static final int FIELD_TYPE_INT = 0; - public static final int FIELD_TYPE_FOLLOWING_STRING = 2; public static final int FIELD_TYPE_STRING = 1; + public static final int FIELD_TYPE_FOLLOWING_STRING = 2; public static final char DELIMITER = '\b'; + private final LittleEndianDataOutputStream outputStream; private final Charset charset = StandardCharsets.UTF_8; private final Lock writer = new ReentrantLock(); + private volatile boolean closed = false; public FaDataOutputStream(OutputStream outputStream) { + if (outputStream == null) { + throw new IllegalArgumentException("Output stream cannot be null"); + } this.outputStream = new LittleEndianDataOutputStream(new BufferedOutputStream(outputStream)); } @Override public void write(int b) throws IOException { + if (closed) throw new IOException("Stream is closed"); writer.lock(); try { outputStream.write(b); @@ -38,9 +47,23 @@ public void write(int b) throws IOException { } } + /** + * Writes a message with header and arguments. + * Supports Integer, Double (as int), and String. + * + * @param header the message header (must not be null) + * @param args the arguments (null values are skipped) + * @throws IOException if an I/O error occurs + */ public void writeMessage(String header, Object... args) throws IOException { + if (header == null) { + throw new IllegalArgumentException("Header cannot be null"); + } + writer.lock(); try { + if (closed) throw new IOException("Stream is closed"); + writeString(header); writeArgs(Arrays.asList(args)); outputStream.flush(); @@ -51,6 +74,9 @@ public void writeMessage(String header, Object... args) throws IOException { @Override public void flush() throws IOException { + if (closed) { + return; + } writer.lock(); try { outputStream.flush(); @@ -63,6 +89,8 @@ public void flush() throws IOException { public void close() throws IOException { writer.lock(); try { + if (closed) return; + closed = true; outputStream.close(); } finally { writer.unlock(); @@ -70,18 +98,34 @@ public void close() throws IOException { } private void writeArgs(List args) throws IOException { - writeInt(args.size()); + if (args == null) { + writeInt(0); + return; + } - for (Object arg : args) { + List validArgs = args.stream() + .filter(arg -> { + if (arg == null) { + return false; + } + return true; + }) + .toList(); + + writeInt(validArgs.size()); + + for (Object arg : validArgs) { if (arg instanceof Double d) { writeByte(FIELD_TYPE_INT); writeInt(d.intValue()); } else if (arg instanceof Integer i) { writeByte(FIELD_TYPE_INT); writeInt(i); - } else if (arg instanceof String value) { + } else if (arg instanceof String str) { writeByte(FIELD_TYPE_STRING); - writeString(value); + writeString(str); + } else { + log.error("Unsupported argument type: {} {}", arg.getClass().getSimpleName(), arg); } } } @@ -95,7 +139,11 @@ private void writeByte(int b) throws IOException { } private void writeString(String string) throws IOException { - outputStream.writeInt(string.length()); - outputStream.write(string.getBytes(charset)); + if (string == null) { + string = ""; + } + byte[] bytes = string.getBytes(charset); + outputStream.writeInt(bytes.length); + outputStream.write(bytes); } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/GPGNetServer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/GPGNetServer.java index ecaa9a6..be4688b 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/GPGNetServer.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/gpgnet/GPGNetServer.java @@ -1,63 +1,63 @@ package com.faforever.iceadapter.gpgnet; -import static com.faforever.iceadapter.debug.Debug.debug; - import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.ice.GameSession; import com.faforever.iceadapter.rpc.RPCService; import com.faforever.iceadapter.util.LockUtil; import com.faforever.iceadapter.util.NetworkToolbox; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + import java.io.IOException; +import java.io.InputStream; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; -import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; -import lombok.Getter; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; +import java.util.stream.Stream; + +import static com.faforever.iceadapter.debug.Debug.debug; @Slf4j +@Data public class GPGNetServer implements AutoCloseable { + private static final Object INSTANCE_LOCK = new Object(); private static GPGNetServer INSTANCE; private final Lock lockSocket = new ReentrantLock(); - private int gpgnetPort; - private int lobbyPort; + @Getter + private final int gpgNetPort; + @Getter + private final int lobbyPort; + private IceAdapter iceAdapter; private RPCService rpcService; private ServerSocket serverSocket; - private volatile GPGNetClient currentClient; - // Used by other services to get a callback on FA connecting - private volatile CompletableFuture clientFuture = new CompletableFuture<>(); + // single reference to current client (atomic for safe reads) + private final AtomicReference currentClient = new AtomicReference<>(); - public void sendToGpgNet(String header, Object... args) { - clientFuture.thenAccept(gpgNetClient -> - gpgNetClient.getLobbyFuture().thenRun(() -> gpgNetClient.sendGpgnetMessage(header, args))); - } + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); @Setter private volatile LobbyInitMode lobbyInitMode = LobbyInitMode.NORMAL; - public static LobbyInitMode getLobbyInitMode() { - return INSTANCE.lobbyInitMode; - } - - public void init(int gpgnetPort, int lobbyPort, RPCService rpcService) { - INSTANCE = this; - this.rpcService = rpcService; - - if (gpgnetPort == 0) { - this.gpgnetPort = NetworkToolbox.findFreeTCPPort(20000, 65536); - log.info("Generated GPGNET_PORT: {}", this.gpgnetPort); + public GPGNetServer(int gpgNetPort, int lobbyPort) { + if (gpgNetPort == 0) { + this.gpgNetPort = NetworkToolbox.findFreeTCPPort(20000, 65536); + log.info("Generated GPGNET_PORT: {}", this.gpgNetPort); } else { - this.gpgnetPort = gpgnetPort; - log.info("Using GPGNET_PORT: {}", this.gpgnetPort); + this.gpgNetPort = gpgNetPort; + log.info("Using GPGNET_PORT: {}", this.gpgNetPort); } if (lobbyPort == 0) { @@ -67,44 +67,78 @@ public void init(int gpgnetPort, int lobbyPort, RPCService rpcService) { this.lobbyPort = lobbyPort; log.info("Using LOBBY_PORT: {}", this.lobbyPort); } + } + + public static LobbyInitMode getLobbyInitMode() { + return INSTANCE != null ? INSTANCE.lobbyInitMode : LobbyInitMode.NORMAL; + } + + public static int getStaticGpgNetPort() { + return INSTANCE != null ? INSTANCE.gpgNetPort : 0; + } + + public static int getStaticLobbyPort() { + return INSTANCE != null ? INSTANCE.lobbyPort : 0; + } + + public void sendToGpgNet(String header, Object... args) { + // fast-path: if we have a client, send directly. Avoid piling up futures. + GPGNetClient client = currentClient.get(); + if (client != null && client.isReadyForLobby()) { + client.sendGpgnetMessage(header, args); + return; + } + log.debug("Dropping GPGNet message because no client ready: {} {}", header, formatArgs(args)); + } + + public void init(IceAdapter iceAdapter, + RPCService rpcService) { + synchronized (INSTANCE_LOCK) { + INSTANCE = this; + } + this.iceAdapter = iceAdapter; + this.rpcService = rpcService; try { - this.serverSocket = new ServerSocket(this.gpgnetPort); + serverSocket = new ServerSocket(gpgNetPort); } catch (IOException e) { log.error("Couldn't start GPGNetServer", e); IceAdapter.close(-1); + return; } - CompletableFuture.runAsync(this::acceptThread, IceAdapter.getExecutor()); - log.info("GPGNetServer started"); + // start accept loop on executor + executor.submit(this::acceptLoop); + log.info("GPGNetServer started on port {}", this.gpgNetPort); } /** - * Represents a client (a game instance) connected to this GPGNetServer + * Represents a connected FA client instance */ @Getter - public class GPGNetClient { + public class GPGNetClient implements AutoCloseable { private volatile GameState gameState = GameState.NONE; private final Socket socket; - private final Thread listenerThread; private volatile boolean stopping = false; private FaDataOutputStream gpgnetOut; private final Lock lockStream = new ReentrantLock(); private final CompletableFuture lobbyFuture = new CompletableFuture<>(); - private GPGNetClient(Socket socket) { + private GPGNetClient(Socket socket) throws IOException { this.socket = socket; + this.gpgnetOut = new FaDataOutputStream(socket.getOutputStream()); - try { - gpgnetOut = new FaDataOutputStream(socket.getOutputStream()); - } catch (IOException e) { - log.error("Could not create GPGNet output steam to FA", e); - } - listenerThread = Thread.startVirtualThread(this::listenerThread); - + // notify RPC layer rpcService.onConnectionStateChanged("Connected"); - log.info("GPGNetClient has connected"); + log.info("GPGNetClient connected from {}", socket.getRemoteSocketAddress()); + + // start listener task on executor + executor.submit(this::listenerLoop); + } + + private boolean isReadyForLobby() { + return lobbyFuture.isDone(); } /** @@ -113,14 +147,15 @@ private GPGNetClient(Socket socket) { private void processGpgnetMessage(String command, List args) { switch (command) { case "GameState" -> { - gameState = GameState.getByName((String) args.get(0)); - log.debug("New GameState: {}", gameState.getName()); + String stateName = (String) args.get(0); + gameState = GameState.getByName(stateName); + log.debug("GameState changed: {}", gameState.getName()); if (gameState == GameState.IDLE) { sendGpgnetMessage( "CreateLobby", lobbyInitMode.getId(), - GPGNetServer.getLobbyPort(), + lobbyPort, IceAdapter.getLogin(), IceAdapter.getId(), 1); @@ -131,183 +166,184 @@ private void processGpgnetMessage(String command, List args) { debug().gameStateChanged(); } case "GameEnded" -> { - if (IceAdapter.getGameSession() != null) { - IceAdapter.getGameSession().setGameEnded(true); + GameSession gs = IceAdapter.getGameSessionSafe(); + if (gs != null) { + gs.setGameEnded(true); log.info("GameEnded received, stopping reconnects..."); } } default -> { - // No need to log, as we are not processing all messages but just forward them via RPC + // forwarding to RPC } } - log.info( - "Received GPGNet message: {} {}", - command, - args.stream().map(Object::toString).collect(Collectors.joining(" "))); + log.info("Received GPGNet message: {} {}", command, formatArgs(args.toArray())); rpcService.onGpgNetMessageReceived(command, args); } - /** - * Send a message to this FA instance via GPGNet - */ public void sendGpgnetMessage(String command, Object... args) { + if (stopping) return; LockUtil.executeWithLock(lockStream, () -> { try { - - gpgnetOut.writeMessage(command, args); - log.info( - "Sent GPGNet message: {} {}", - command, - Arrays.stream(args).map(Object::toString).collect(Collectors.joining(" "))); + if (gpgnetOut != null) { + gpgnetOut.writeMessage(command, args); + log.info("Sent GPGNet message: {} {}", command, formatArgs(args)); + } } catch (IOException e) { log.error("Error while communicating with FA (output), assuming shutdown", e); - GPGNetServer.this.onGpgnetConnectionLost(); + // schedule connection lost handling outside of lock to avoid potential deadlocks + executor.submit(GPGNetServer.this::onGpgnetConnectionLost); } }); } - /** - * Listens for incoming messages from FA - */ - private void listenerThread() { - log.debug("Listening for GPG messages"); - boolean triggerActive = - false; // Prevents a race condition between this thread and the thread that has created this object - // and is now going to set GPGNetServer.currentClient - try (var inputStream = socket.getInputStream(); - var gpgnetIn = new FaDataInputStream(inputStream)) { - while (!Thread.currentThread().isInterrupted() - && (!triggerActive || currentClient == this) - && !stopping) { + private void listenerLoop() { + log.debug("Listening for GPG messages from {}", socket.getRemoteSocketAddress()); + try (InputStream in = socket.getInputStream(); var gpgnetIn = new FaDataInputStream(in)) { + while (!stopping) { String command = gpgnetIn.readString(); List args = gpgnetIn.readChunks(); - processGpgnetMessage(command, args); - - if (!triggerActive && currentClient != null) { - triggerActive = - true; // From now on we will check GPGNetServer.currentClient to see if we should stop + // If this client is no longer the current active one, stop listening + GPGNetClient active = currentClient.get(); + if (active != this) { + log.info("Listener noticing it's no longer active client, stopping listener: {}", socket.getRemoteSocketAddress()); + break; } + + processGpgnetMessage(command, args); } + } catch (SocketException se) { + log.warn("SocketException in listener, assuming FA shutdown: {}", se.toString()); + executor.submit(GPGNetServer.this::onGpgnetConnectionLost); } catch (IOException e) { log.error("Error while communicating with FA (input), assuming shutdown", e); - GPGNetServer.this.onGpgnetConnectionLost(); + executor.submit(GPGNetServer.this::onGpgnetConnectionLost); } - log.debug("No longer listening for GPGPNET from FA"); + log.debug("GPGNet listener exiting for {}", socket.getRemoteSocketAddress()); } + @Override public void close() { stopping = true; - this.listenerThread.interrupt(); - log.debug("Closing GPGNetClient"); - try { socket.close(); } catch (IOException e) { - log.error("Error while closing GPGNetClient socket", e); + log.warn("Error closing client socket", e); } } } /** - * Closes all connections to the current client, removes this client. - * To be called on encountering an error during the communication with the game instance - * or on receiving an incoming connection request while still connected to a different instance. - * THIS TRIGGERS A DISCONNECT FROM ALL PEERS AND AN ICE SHUTDOWN. + * Called when the connection to FA is lost or a new connection is established while already connected. + * This method removes and closes the current client and triggers ICE shutdown outside of the client lock. */ private void onGpgnetConnectionLost() { log.info("GPGNet connection lost"); - LockUtil.executeWithLock(lockSocket, () -> { - if (currentClient != null) { - currentClient.close(); - currentClient = null; - - if (clientFuture.isDone()) { - clientFuture = new CompletableFuture<>(); - } + // remove and close the client under lock + LockUtil.executeWithLock(lockSocket, () -> { + GPGNetClient prevClient = currentClient.getAndSet(null); + if (prevClient != null) { + prevClient.close(); + // create a fresh lobby future if necessary is handled per-client rpcService.onConnectionStateChanged("Disconnected"); - - IceAdapter.onFAShutdown(); } }); - debug().gpgnetConnectedDisconnected(); + + // perform the potentially blocking and cross-module shutdown outside of the lock to avoid deadlocks + if (currentClient.get() != null) { + iceAdapter.onFAShutdown(); + debug().gpgnetConnectedDisconnected(); + } } - /** - * Listens for incoming connections from a game instance - */ - private void acceptThread() { - while (!Thread.currentThread().isInterrupted()) { - log.info("Listening for incoming connections from game"); + private void acceptLoop() { + log.info("Accept loop started for GPGNetServer"); + while (serverSocket != null && !serverSocket.isClosed()) { try { - // The socket declaration must not be moved into a try-with-resources block, as the socket must not be - // closed. It is passed into the GPGNetClient. Socket socket = serverSocket.accept(); + // handle new connection serially under lock LockUtil.executeWithLock(lockSocket, () -> { - if (currentClient != null) { - onGpgnetConnectionLost(); + GPGNetClient existing = currentClient.get(); + if (existing != null) { + // close existing client first (synchronously) + existing.close(); + currentClient.set(null); } - currentClient = new GPGNetClient(socket); - clientFuture.complete(currentClient); - - debug().gpgnetConnectedDisconnected(); + try { + GPGNetClient client = new GPGNetClient(socket); + currentClient.set(client); + // when lobby is ready, the client will complete its lobbyFuture + debug().gpgnetConnectedDisconnected(); + } catch (IOException e) { + log.error("Failed to create GPGNetClient", e); + try { + socket.close(); + } catch (IOException ex) { + log.warn("Failed to close socket after failed client creation", ex); + } + } }); - } catch (SocketException e) { - log.error("Game thread socket crashed", e); - // TODO: Clarify - // If we return here, why do we have the code in a while loop? - // We could also not return and try to reconnect? - return; + + } catch (SocketException se) { + log.info("Server socket closed or interrupted: {}", se.toString()); + break; } catch (IOException e) { log.error("Could not listen on socket", e); } } - } - - /** - * @return whether the game is connected via GPGNET - */ - public static boolean isConnected() { - return INSTANCE.currentClient != null; - } - public static String getGameStateString() { - return getGameState().map(GameState::getName).orElse(""); + log.info("Accept loop terminating"); } - public static Optional getGameState() { - return Optional.ofNullable(INSTANCE.currentClient).map(GPGNetClient::getGameState); + public boolean isConnected() { + return currentClient.get() != null; } - public static int getGpgnetPort() { - return INSTANCE.gpgnetPort; + public boolean isServerRunning() { + return serverSocket != null && !serverSocket.isClosed(); } - public static int getLobbyPort() { - return INSTANCE.lobbyPort; + public static Optional getGameState() { + return Optional.ofNullable(INSTANCE) + .map(s -> s.currentClient.get()) + .map(GPGNetClient::getGameState); } /** * Stops the GPGNetServer and thereby the connection to a currently connected client */ + @Override public void close() { - if (currentClient != null) { - currentClient.close(); - currentClient = null; - clientFuture = new CompletableFuture<>(); - } + log.info("Stopping GPGNetServer"); - if (serverSocket != null) { - try { + executor.shutdown(); + + // stop accept loop by closing server socket + try { + if (serverSocket != null && !serverSocket.isClosed()) { serverSocket.close(); - } catch (IOException e) { - log.error("Could not close gpgnet server socket", e); } + } catch (IOException e) { + log.warn("Could not close gpgnet server socket", e); } + + // close current client + GPGNetClient client = currentClient.getAndSet(null); + if (client != null) { + client.close(); + } + log.info("GPGNetServer stopped"); } + + // utility: format args to string + private static String formatArgs(Object... args) { + return Stream.of(args) + .map(arg -> arg instanceof Double d ? d.intValue() + "" : String.valueOf(arg)) + .collect(java.util.stream.Collectors.joining(" ")); + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/AgentSuccessMonitor.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/AgentSuccessMonitor.java new file mode 100644 index 0000000..2a198da --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/AgentSuccessMonitor.java @@ -0,0 +1,11 @@ +package com.faforever.iceadapter.ice; + +import java.util.concurrent.CompletableFuture; + +public interface AgentSuccessMonitor { + + CompletableFuture start(); + + void shutdown(); + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/CandidatesMessage.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/CandidatesMessage.java index 1a965c6..4153c19 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/CandidatesMessage.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/CandidatesMessage.java @@ -1,13 +1,26 @@ package com.faforever.iceadapter.ice; import java.util.List; +import java.util.stream.Collectors; /** * Represents and IceMessage, consists out of candidates and ufrag aswell as password */ -public record CandidatesMessage( - int srcId, int destId, String password, String ufrag, List candidates) { +public record CandidatesMessage(int srcId, + int destId, + String password, + String ufrag, + List candidates) { public CandidatesMessage { candidates = List.copyOf(candidates); } + + public String toStrCandidates() { + if (candidates == null) { + return ""; + } + return candidates.stream() + .map(it -> it.type().toString() + "(" + it.protocol() + ")") + .collect(Collectors.joining(", ")); + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/GameSession.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/GameSession.java index 82e735c..914eda1 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/GameSession.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/GameSession.java @@ -1,72 +1,109 @@ package com.faforever.iceadapter.ice; -import static com.faforever.iceadapter.debug.Debug.debug; - -import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.IceOptions; +import com.faforever.iceadapter.gpgnet.GPGNetServer; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerModule; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import com.faforever.iceadapter.rpc.RPCService; +import com.faforever.iceadapter.services.ConnectService; +import com.faforever.iceadapter.services.IceAsync; +import com.faforever.iceadapter.services.IceTrigger; +import com.faforever.iceadapter.services.impl.ConnectServiceControlledImpl; +import com.faforever.iceadapter.services.impl.ConnectServiceHandler; +import com.faforever.iceadapter.services.impl.ConnectServiceNotControlledImpl; +import com.faforever.iceadapter.services.impl.IceAsyncImpl; import com.faforever.iceadapter.telemetry.CoturnServer; -import com.faforever.iceadapter.util.PingWrapper; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; -import java.net.URI; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.OptionalDouble; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; +import com.faforever.iceadapter.util.ExecutorHolder; +import com.faforever.iceadapter.util.TrayIcon; +import kotlin.Pair; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.ice4j.Transport; -import org.ice4j.TransportAddress; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static com.faforever.iceadapter.debug.Debug.debug; /** * Represents a game session and the current ICE status/communication with all peers * Is created by a JoinGame or HostGame event (via RPC), is destroyed by a gpgnet connection breakdown */ @Slf4j -public class GameSession { +@RequiredArgsConstructor +public class GameSession implements IceGameSession { - private static final String STUN = "stun"; - private static final String TURN = "turn"; + private static final List iceServers = createIceServers(); - private static final List PUBLIC_STUN_SERVERS = List.of( - new TransportAddress("stun.cloudflare.com", 3478, Transport.UDP), - new TransportAddress("stun.l.google.com", 19302, Transport.UDP), - new TransportAddress("stun.sipgate.net", 3478, Transport.UDP)); + public static List getAllServers() { + return iceServers; + } @Getter private final Map peers = new ConcurrentHashMap<>(); + private final RPCService rpcService; + private final IceOptions options; + + private final IceAsync iceAsync = new IceAsyncImpl(ExecutorHolder.getExecutor(), ExecutorHolder.getScheduledExecutor()); + private final ConnectService controlledConnectService = new ConnectServiceControlledImpl(this, iceAsync); + private final ConnectService notControlledConnectService = new ConnectServiceNotControlledImpl(this, iceAsync); + private final ConnectService connectServiceHandler = new ConnectServiceHandler(controlledConnectService, notControlledConnectService); + private final IceTrigger iceTrigger = new IceTrigger(iceAsync, connectServiceHandler); + private final IceServerChecker iceServerChecker; + @Getter @Setter private volatile boolean gameEnded = false; - public GameSession() {} + public GameSession(RPCService rpcService, IceOptions options) { + this.rpcService = rpcService; + this.options = options; + iceServerChecker = new IceServerChecker(options, this); + iceServerChecker.start(); + } /** * Initiates a connection to a peer (ICE) * * @return the port the ice adapter will be listening/sending for FA */ - public int connectToPeer(String remotePlayerLogin, int remotePlayerId, boolean offer, int preferredPort) { + public int connectToPeer(String remotePlayerLogin, + int remotePlayerId, + boolean offer, + int preferredPort, + AllowCombination combination) { if (peers.containsKey(remotePlayerId)) { - reconnectToPeer(remotePlayerId); + reCreatePeer(remotePlayerId); + debug().connectToPeer(remotePlayerId, remotePlayerLogin, offer); return peers.get(remotePlayerId).getLocalPort(); } - Peer peer = new Peer(this, remotePlayerId, remotePlayerLogin, offer, preferredPort); + Peer peer = new Peer(remotePlayerId, remotePlayerLogin, offer, preferredPort, getLobbyPort(), getDisabledModules()); + peer.init(); + peer.setCombination(combination); + peer.initModules(); + peer.addEventListener(iceTrigger); + peer.startInitPeer(); peers.put(remotePlayerId, peer); debug().connectToPeer(remotePlayerId, remotePlayerLogin, offer); return peer.getLocalPort(); } + private void reCreatePeer(Integer remotePlayerId) { + Peer reconnectPeer = peers.get(remotePlayerId); + if (Objects.nonNull(reconnectPeer)) { + String remotePlayerLogin = reconnectPeer.getRemoteLogin(); + boolean offer = reconnectPeer.isLocalOffer(); + int port = reconnectPeer.getLocalPort(); + AllowCombination combination = reconnectPeer.getCombination(); + + disconnectFromPeer(remotePlayerId); + connectToPeer(remotePlayerLogin, remotePlayerId, offer, port, combination); + } + } + /** * Disconnects from a peer (ICE) */ @@ -80,22 +117,6 @@ public void disconnectFromPeer(int remotePlayerId) { // TODO: still attempting to ICE } - /** - * Does a manual {@link #disconnectFromPeer} and {@link #connectToPeer}. - * Uses the same port that was on the previous connection. - */ - public void reconnectToPeer(Integer remotePlayerId) { - Peer reconnectPeer = peers.get(remotePlayerId); - if (Objects.nonNull(reconnectPeer)) { - String remotePlayerLogin = reconnectPeer.getRemoteLogin(); - boolean offer = reconnectPeer.isLocalOffer(); - int port = reconnectPeer.getLocalPort(); - - disconnectFromPeer(remotePlayerId); - connectToPeer(remotePlayerLogin, remotePlayerId, offer, port); - } - } - /** * Stops the connection to all peers and all ice agents */ @@ -103,10 +124,39 @@ public void close() { log.info("Closing gameSession"); peers.values().forEach(Peer::close); peers.clear(); + iceServerChecker.stop(); } - @Getter - private static final List iceServers = new ArrayList<>(); + + public List getIceServers() { + return iceServers; + } + + @Override + public Optional getPeer(int peerId) { + return Optional.ofNullable(peers.get(peerId)); + } + + private Set getDisabledModules() { + Set disabledModules = new HashSet<>(); + if (!options.isManualStrategyConnection()) { + disabledModules.add(PeerModule.CHANGE_AGENT_STRATEGY); + } + if (options.isForceRelay() || options.isManualCombinationConnection()) { + disabledModules.add(PeerModule.AUTO_SETTING_ALLOW_CANDIDATE); + } + return disabledModules; + } + + public static List createIceServers() { + List iceServers = new ArrayList<>(); + addDefaultIceServers(iceServers); + return iceServers; + } + + public static void addDefaultIceServers(List iceServers) { + iceServers.addAll(IceServer.createPublicServers()); + } /** * Set ice servers (to be used for harvesting candidates) @@ -114,97 +164,47 @@ public void close() { */ public static void setIceServers(List> iceServersData) { iceServers.clear(); - - PUBLIC_STUN_SERVERS.forEach(stunServer -> { - var iceServer = new IceServer(); - iceServer.getStunAddresses().add(stunServer); - iceServers.add(iceServer); - }); + addDefaultIceServers(iceServers); if (iceServersData.isEmpty()) { return; } - // For caching RTT to a given host (the same host can appear in multiple urls) - LoadingCache> hostRTTCache = CacheBuilder.newBuilder() - .build(new CacheLoader<>() { - @Override - public CompletableFuture load(String host) { - return PingWrapper.getLatency(host, IceAdapter.getPingCount()) - .thenApply(OptionalDouble::of) - .exceptionally(ex -> OptionalDouble.empty()); - } - }); - - Set coturnServers = new HashSet<>(); - - for (Map iceServerData : iceServersData) { - IceServer iceServer = new IceServer(); - - if (iceServerData.containsKey("username")) { - iceServer.setTurnUsername((String) iceServerData.get("username")); - } - if (iceServerData.containsKey("credential")) { - iceServer.setTurnCredential((String) iceServerData.get("credential")); - } - - if (iceServerData.containsKey("urls")) { - List urls; - Object urlsData = iceServerData.get("urls"); - if (urlsData instanceof List) { - urls = (List) urlsData; - } else { - urls = Collections.singletonList((String) iceServerData.get("url")); - } - - urls.stream() - .map(stringUrl -> { - try { - return new URI(stringUrl); - } catch (Exception e) { - log.warn("Invalid ICE server URI: {}", stringUrl); - return null; - } - }) - .filter(Objects::nonNull) - .forEach(uri -> { - String host = uri.getHost(); - int port = uri.getPort() == -1 ? 3478 : uri.getPort(); - Transport transport = Optional.ofNullable(uri.getQuery()).stream() - .flatMap(query -> Arrays.stream(query.split("&"))) - .map(param -> param.split("=")) - .filter(param -> param.length == 2) - .filter(param -> param[0].equals("transport")) - .map(param -> param[1]) - .map(Transport::parse) - .findFirst() - .orElse(Transport.UDP); - - TransportAddress address = new TransportAddress(host, port, transport); - switch (uri.getScheme()) { - case STUN -> iceServer.getStunAddresses().add(address); - case TURN -> iceServer.getTurnAddresses().add(address); - default -> log.warn("Invalid ICE server protocol: {}", uri); - } - - if (IceAdapter.getPingCount() > 0) { - iceServer.setRoundTripTime(hostRTTCache.getUnchecked(host)); - } - - coturnServers.add(new CoturnServer("n/a", host, port, null)); - }); - } - - iceServers.add(iceServer); - } + Pair, Set> pair = IceServer.mapperFromMap(iceServersData); + + iceServers.addAll(pair.getFirst()); + debug().updateCoturnList(pair.getSecond()); + + + log.info("Ice Servers set, total addresses: {}", iceServers.size()); + } + + public void onIceMessageFromRPC(Peer peer, CandidatesMessage message) { + iceAsync.runAsync("onIceMessageFromRPC", peer, () -> connectServiceHandler.onMessageFromRPC(peer, message)); + } - debug().updateCoturnList(coturnServers); + @Override + public int getLobbyPort() { + return GPGNetServer.getStaticLobbyPort(); + } + + @Override + public int getMyId() { + return options.getId(); + } + + @Override + public void sendToRpc(CandidatesMessage message) { + rpcService.onIceMsg(message); + } + + @Override + public void onConnected(Peer peer, boolean connected) { + rpcService.onConnected(getMyId(), peer.getRemoteId(), connected); + } - log.info( - "Ice Servers set, total addresses: {}", - iceServers.stream() - .mapToInt(iceServer -> iceServer.getStunAddresses().size() - + iceServer.getTurnAddresses().size()) - .sum()); + @Override + public void showMessage(String message) { + TrayIcon.showMessage(message); } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceGameSession.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceGameSession.java new file mode 100644 index 0000000..bf2af24 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceGameSession.java @@ -0,0 +1,28 @@ +package com.faforever.iceadapter.ice; + +import com.faforever.iceadapter.ice.peer.Peer; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface IceGameSession { + + Map getPeers(); + + Optional getPeer(int peerId); + + List getIceServers(); + + boolean isGameEnded(); + + int getLobbyPort(); + + int getMyId(); + + void sendToRpc(CandidatesMessage message); + + void onConnected(Peer peer, boolean connected); + + void showMessage(String message); +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServer.java index aa4ea5b..786af8e 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServer.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServer.java @@ -1,27 +1,147 @@ package com.faforever.iceadapter.ice; import com.faforever.iceadapter.IceAdapter; -import java.util.ArrayList; -import java.util.List; -import java.util.OptionalDouble; -import java.util.concurrent.CompletableFuture; -import java.util.regex.Pattern; +import com.faforever.iceadapter.telemetry.CoturnServer; +import com.faforever.iceadapter.util.PingUtil; +import kotlin.Pair; import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.Transport; import org.ice4j.TransportAddress; +import java.net.URI; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; + @Data +@Slf4j +@RequiredArgsConstructor public class IceServer { - private List stunAddresses = new ArrayList<>(); - private List turnAddresses = new ArrayList<>(); + private static final List PUBLIC_STUN_SERVERS = List.of( + new TransportAddress("stun.cloudflare.com", 3478, Transport.UDP), + new TransportAddress("stun.l.google.com", 19302, Transport.UDP), + new TransportAddress("stun.sipgate.net", 3478, Transport.UDP)); + + private static final String STUN = "stun"; + private static final String TURN = "turn"; + private static final String TURNS = "turns"; + + private final TypeServer type; + private final TransportAddress address; private String turnUsername = ""; private String turnCredential = ""; + private boolean enabled = true; + private boolean auto = true; private CompletableFuture roundTripTime = CompletableFuture.completedFuture(OptionalDouble.empty()); public static final Pattern urlPattern = Pattern.compile( - "(?stun|turn):(?(\\w|\\.)+)(:(?\\d+))?(\\?transport=(?(tcp|udp)))?"); + "(?stun|turn|turns):(?(\\w|\\.)+)(:(?\\d+))?(\\?transport=(?(tcp|udp)))?"); + + public boolean hasAcceptableLatency(double latency) { + OptionalDouble rtt = roundTripTime.join(); + return rtt.isEmpty() || rtt.getAsDouble() < latency; + } + + public String strTripTime() { + try { + OptionalDouble rtt = roundTripTime.join(); + if (rtt.isPresent()) { + return "%dms".formatted(Math.round(rtt.getAsDouble())); + } + + return "-"; + } catch (Exception e) { + return "Error"; + } + } + + public boolean isStun() { + return type == TypeServer.STUN; + } + + public boolean isTurn() { + return type == TypeServer.TURN; + } + + public static List createPublicServers() { + return PUBLIC_STUN_SERVERS.stream() + .map(stunServer -> new IceServer(TypeServer.STUN, stunServer)) + .toList(); + } + + public static Pair, Set> mapperFromMap(List> iceServersData) { + List iceServers = new ArrayList<>(); + + Set coturnServers = new HashSet<>(); + + for (Map iceServerData : iceServersData) { + + + if (iceServerData.containsKey("urls")) { + List urls; + Object urlsData = iceServerData.get("urls"); + if (urlsData instanceof List) { + urls = (List) urlsData; + } else { + urls = Collections.singletonList((String) iceServerData.get("url")); + } + + urls.stream() + .map(stringUrl -> { + try { + return new URI(stringUrl); + } catch (Exception e) { + log.warn("Invalid ICE server URI: {}", stringUrl); + return null; + } + }) + .filter(Objects::nonNull) + .forEach(uri -> { + String host = uri.getHost(); + int port = uri.getPort() == -1 ? 3478 : uri.getPort(); + Transport transport = Optional.ofNullable(uri.getQuery()).stream() + .flatMap(query -> Arrays.stream(query.split("&"))) + .map(param -> param.split("=")) + .filter(param -> param.length == 2) + .filter(param -> param[0].equals("transport")) + .map(param -> param[1]) + .map(Transport::parse) + .findFirst() + .orElse(Transport.UDP); + + TransportAddress address = new TransportAddress(host, port, transport); + TypeServer type = TypeServer.TURN; + switch (uri.getScheme()) { + case STUN -> type = TypeServer.STUN; + case TURNS, TURN -> type = TypeServer.TURN; + default -> log.warn("Invalid ICE server protocol: {}", uri); + } + IceServer iceServer = new IceServer(type, address); + if (iceServerData.containsKey("username")) { + iceServer.setTurnUsername((String) iceServerData.get("username")); + } + if (iceServerData.containsKey("credential")) { + iceServer.setTurnCredential((String) iceServerData.get("credential")); + } + if (IceAdapter.getPingCount() > 0) { + iceServer.setRoundTripTime(PingUtil.getLatency(host)); + } + iceServers.add(iceServer); + + coturnServers.add(new CoturnServer("n/a", host, port, null)); + }); + } + + + } + + return new Pair<>(iceServers, coturnServers); + } - public boolean hasAcceptableLatency() { - OptionalDouble rtt = this.getRoundTripTime().join(); - return rtt.isEmpty() || rtt.getAsDouble() < IceAdapter.getAcceptableLatency(); + public enum TypeServer { + STUN, + TURN; } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServerChecker.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServerChecker.java new file mode 100644 index 0000000..bc948b7 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceServerChecker.java @@ -0,0 +1,49 @@ +package com.faforever.iceadapter.ice; + +import com.faforever.iceadapter.IceOptions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Slf4j +@RequiredArgsConstructor +public class IceServerChecker { + private static final int INTERVAL = 3; + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final IceOptions options; + private final IceGameSession gameSession; + + private ScheduledFuture scheduledFuture; + + public void start() { + if (scheduledFuture != null) { + return; + } + scheduledFuture = scheduler.scheduleAtFixedRate(this::checkerThread, + 0, + INTERVAL, + TimeUnit.MINUTES); + } + + private void checkerThread() { + gameSession.getIceServers().stream() + .filter(IceServer::isTurn) + .filter(IceServer::isAuto) + .forEach(server -> { + server.setEnabled(server.hasAcceptableLatency(options.getAcceptableLatency())); + }); + } + + public void stop() { + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + scheduledFuture = null; + } + scheduler.shutdown(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceState.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceState.java index 536e780..126c126 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceState.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/IceState.java @@ -1,13 +1,13 @@ package com.faforever.iceadapter.ice; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.RequiredArgsConstructor; -@Getter -@AllArgsConstructor /** * IceState, does not match WebRTC states, represents peer connection "lifecycle" */ +@Getter +@RequiredArgsConstructor public enum IceState { NEW("new"), GATHERING("gathering"), @@ -18,4 +18,9 @@ public enum IceState { DISCONNECTED("disconnected"); private final String message; + + @Override + public String toString() { + return message; + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/ModuleBase.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/ModuleBase.java new file mode 100644 index 0000000..fa15a06 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/ModuleBase.java @@ -0,0 +1,22 @@ +package com.faforever.iceadapter.ice; + +public interface ModuleBase { + + default void start() { + } + + default void stop() { + } + + default void init() { + } + + default Boolean isRunning() { + return null; + } + + default Boolean isEnabled() { + return null; + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/Peer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/Peer.java deleted file mode 100644 index 0d1e575..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/Peer.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.faforever.iceadapter.ice; - -import com.faforever.iceadapter.IceAdapter; -import com.faforever.iceadapter.gpgnet.GPGNetServer; -import com.faforever.iceadapter.util.LockUtil; -import java.io.IOException; -import java.net.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import lombok.Getter; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; - -/** - * Represents a peer in the current game session which we are connected to - */ -@Getter -@Slf4j -public class Peer { - private final GameSession gameSession; - - private final int remoteId; - private final String remoteLogin; - private final boolean localOffer; // Do we offer or are we waiting for a remote offer - private final int preferredPort; - - public volatile boolean closing = false; - - private final PeerIceModule ice = new PeerIceModule(this); - private final DatagramSocket faSocket; // Socket on which we are listening for FA / sending data to FA - private final Lock lockSocketSend = new ReentrantLock(); - - public Peer(GameSession gameSession, int remoteId, String remoteLogin, boolean localOffer, int preferredPort) { - this.gameSession = gameSession; - this.remoteId = remoteId; - this.remoteLogin = remoteLogin; - this.localOffer = localOffer; - this.preferredPort = preferredPort; - - log.debug( - "Peer created: {}, localOffer: {}, preferredPort: {}", getPeerIdentifier(), localOffer, preferredPort); - - faSocket = initForwarding(preferredPort); - - CompletableFuture.runAsync(this::faListener, IceAdapter.getExecutor()); - - if (localOffer) { - CompletableFuture.runAsync(ice::initiateIce, IceAdapter.getExecutor()); - } - } - - public int getLocalPort() { - return faSocket.getLocalPort(); - } - - /** - * Starts waiting for data from FA - */ - @SneakyThrows(SocketException.class) - private DatagramSocket initForwarding(int port) { - try { - DatagramSocket socket = new DatagramSocket(port); - log.debug("Now forwarding data to peer {}", getPeerIdentifier()); - return socket; - } catch (SocketException e) { - log.error("Could not create socket for peer: {}", getPeerIdentifier(), e); - throw e; - } - } - - /** - * Forwards data received on ICE to FA - * @param data - * @param offset - * @param length - */ - void onIceDataReceived(byte[] data, int offset, int length) { - LockUtil.executeWithLock(lockSocketSend, () -> { - try { - DatagramPacket packet = new DatagramPacket( - data, offset, length, InetAddress.getByName("127.0.0.1"), GPGNetServer.getLobbyPort()); - faSocket.send(packet); - } catch (UnknownHostException e) { - } catch (IOException e) { - if (closing) { - log.debug( - "Ignoring error the send packet because the connection was closed {}", getPeerIdentifier()); - } else { - log.error( - "Error while writing to local FA as peer (probably disconnecting from peer) {}", - getPeerIdentifier(), - e); - } - } - }); - } - - /** - * This method get's invoked by the thread listening for data from FA - */ - private void faListener() { - byte[] data = new byte - [65536]; // 64KiB = UDP MTU, in practice due to ethernet frames being <= 1500 B, this is often not used - while (!Thread.currentThread().isInterrupted() && IceAdapter.getGameSession() == gameSession && !closing) { - try { - DatagramPacket packet = new DatagramPacket(data, data.length); - faSocket.receive(packet); - ice.onFaDataReceived(data, packet.getLength()); - } catch (IOException e) { - if (closing) { - log.debug( - "Ignoring error the receive packet because the connection was closed as peer {}", - getPeerIdentifier()); - } else { - log.debug( - "Error while reading from local FA as peer (probably disconnecting from peer) {}", - getPeerIdentifier(), - e); - } - return; - } - } - log.debug("No longer listening for messages from FA"); - } - - public void close() { - if (closing) { - return; - } - - log.info("Closing peer for player {}", getPeerIdentifier()); - - closing = true; - faSocket.close(); - - ice.close(); - } - - /** - * @return %username%(%id%) - */ - public String getPeerIdentifier() { - return "%s(%d)".formatted(this.remoteLogin, this.remoteId); - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectionSuccessMonitor.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectionSuccessMonitor.java new file mode 100644 index 0000000..5fb3515 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectionSuccessMonitor.java @@ -0,0 +1,99 @@ +package com.faforever.iceadapter.ice; + +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.util.IceUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.*; + +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.concurrent.*; + +import static org.ice4j.ice.Agent.PROPERTY_ICE_PROCESSING_STATE; + +@Slf4j +@RequiredArgsConstructor +public class PeerConnectionSuccessMonitor implements PropertyChangeListener, AgentSuccessMonitor { + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private ScheduledFuture scheduledFuture; + private CompletableFuture future; + private String name = ""; + private final long timeoutMs; + private final Peer peer; + + @Override + public CompletableFuture start() { + future = new CompletableFuture<>(); + if (peer == null) { + log.error("Peer is null. Aborting."); + future.complete(false); + return future; + } + Agent agent = peer.getAgent(); + if (agent == null) { + log.error("Agent is null. Aborting."); + future.complete(false); + return future; + } + IceMediaStream mediaStream = peer.getMediaStream(); + if (mediaStream == null) { + log.error("Media stream is null. Aborting."); + future.complete(false); + return future; + } + + name = peer.getPeerIdentifier(); + + agent.addStateChangeListener(this); + mediaStream.addPairChangeListener(this); + + scheduledFuture = scheduler.schedule(this::agentTimeoutFailed, timeoutMs, TimeUnit.MILLISECONDS); + + return future; + } + + @Override + public void propertyChange(PropertyChangeEvent event) { + + if (PROPERTY_ICE_PROCESSING_STATE.equals(event.getPropertyName())) { + IceProcessingState state = (IceProcessingState) event.getNewValue(); + + if (state.isEstablished()) { + + IceUtils.getFirstComponent(peer.getMediaStream()) + .map(Component::getSelectedPair) + .ifPresent(this::onPairSucceeded); + } + + if (state == IceProcessingState.FAILED) { + agentConnectionFailed(); + } + } + } + + private void onPairSucceeded(CandidatePair pair) { + Thread.currentThread().setName(name); + log.debug("✅ Successful: Pair is SUCCEEDED: {}", pair); + future.complete(true); + } + + private void agentTimeoutFailed() { + Thread.currentThread().setName(name); + log.info("❌ Timeout: Time has run out to try to connect by {} ms", timeoutMs); + future.complete(false); + } + + private void agentConnectionFailed() { + Thread.currentThread().setName(name); + log.warn("❌ Agent state failed"); + future.complete(false); + } + + public void shutdown() { + scheduler.shutdown(); + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + } + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectivityCheckerModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectivityCheckerModule.java deleted file mode 100644 index bd67289..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerConnectivityCheckerModule.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.faforever.iceadapter.ice; - -import static com.faforever.iceadapter.debug.Debug.debug; - -import com.faforever.iceadapter.IceAdapter; -import com.faforever.iceadapter.util.LockUtil; -import com.google.common.primitives.Longs; -import java.util.Arrays; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -/** - * Periodically sends echo requests via the ICE data channel and initiates a reconnect after timeout - * ONLY THE OFFERING ADAPTER of a connection will send echos and reoffer. - */ -@Slf4j -public class PeerConnectivityCheckerModule { - - private static final int ECHO_INTERVAL = 1000; - - private final PeerIceModule ice; - private final Lock lockIce = new ReentrantLock(); - private volatile boolean running = false; - private volatile Thread checkerThread; - - @Getter - private float averageRTT = 0.0f; - - @Getter - private long lastPacketReceived; - - @Getter - private long echosReceived = 0; - - @Getter - private long invalidEchosReceived = 0; - - public PeerConnectivityCheckerModule(PeerIceModule ice) { - this.ice = ice; - } - - void start() { - LockUtil.executeWithLock(lockIce, () -> { - if (running) { - return; - } - - running = true; - log.debug("Starting connectivity checker for peer {}", ice.getPeer().getRemoteId()); - - averageRTT = 0.0f; - lastPacketReceived = System.currentTimeMillis(); - - checkerThread = Thread.ofVirtual() - .name(getThreadName()) - .uncaughtExceptionHandler((t, e) -> log.error("Thread {} crashed unexpectedly", t.getName(), e)) - .start(this::checkerThread); - }); - } - - private String getThreadName() { - return "connectivityChecker-" + ice.getPeer().getRemoteId(); - } - - void stop() { - LockUtil.executeWithLock(lockIce, () -> { - if (!running) { - return; - } - - running = false; - - if (checkerThread != null) { - checkerThread.interrupt(); - checkerThread = null; - } - }); - } - - /** - * an echo has been received, RTT and last_received will be updated - * @param data - * @param offset - * @param length - */ - void echoReceived(byte[] data, int offset, int length) { - echosReceived++; - - if (length != 9) { - log.trace("Received echo of wrong length, length: {}", length); - invalidEchosReceived++; - } - - int rtt = - (int) (System.currentTimeMillis() - Longs.fromByteArray(Arrays.copyOfRange(data, offset + 1, length))); - if (averageRTT == 0) { - averageRTT = rtt; - } else { - averageRTT = (float) averageRTT * 0.8f + (float) rtt * 0.2f; - } - - lastPacketReceived = System.currentTimeMillis(); - - debug().peerConnectivityUpdate(ice.getPeer()); - // System.out.printf("Received echo from %d after %d ms, averageRTT: %d ms", ice.getPeer().getRemoteId(), - // rtt, (int) averageRTT); - } - - private void checkerThread() { - while (!Thread.currentThread().isInterrupted() && running) { - log.trace("Running connectivity checker"); - - Peer peer = ice.getPeer(); - byte[] data = new byte[9]; - data[0] = 'e'; - - // Copy current time (long, 8 bytes) into array after leading prefix indicating echo - System.arraycopy(Longs.toByteArray(System.currentTimeMillis()), 0, data, 1, 8); - - ice.sendViaIce(data, 0, data.length); - - debug().peerConnectivityUpdate(peer); - - try { - Thread.sleep(ECHO_INTERVAL); - } catch (InterruptedException e) { - log.warn( - "{} (sleeping checkerThread) was interrupted", - Thread.currentThread().getName()); - return; - } - - if (System.currentTimeMillis() - lastPacketReceived > 10000) { - log.warn( - "Didn't receive any answer to echo requests for the past 10 seconds from {}, aborting connection", - peer.getRemoteLogin()); - CompletableFuture.runAsync(ice::onConnectionLost, IceAdapter.getExecutor()); - return; - } - } - - log.info("{} stopped gracefully", Thread.currentThread().getName()); - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java deleted file mode 100644 index b3a97f0..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerIceModule.java +++ /dev/null @@ -1,586 +0,0 @@ -package com.faforever.iceadapter.ice; - -import static com.faforever.iceadapter.debug.Debug.debug; -import static com.faforever.iceadapter.ice.IceState.*; - -import com.faforever.iceadapter.IceAdapter; -import com.faforever.iceadapter.rpc.RPCService; -import com.faforever.iceadapter.util.CandidateUtil; -import com.faforever.iceadapter.util.LockUtil; -import com.faforever.iceadapter.util.TrayIcon; -import java.io.IOException; -import java.net.DatagramPacket; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; -import lombok.Getter; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.ice4j.TransportAddress; -import org.ice4j.ice.*; -import org.ice4j.ice.harvest.StunCandidateHarvester; -import org.ice4j.ice.harvest.TurnCandidateHarvester; -import org.ice4j.security.LongTermCredential; - -@Getter -@Slf4j -public class PeerIceModule { - @Setter - private static RPCService rpcService; - - private static boolean ALLOW_HOST = true; - private static boolean ALLOW_REFLEXIVE = true; - private static boolean ALLOW_RELAY = true; - - public static void setForceRelay(boolean forceRelay) { - if (forceRelay) { - ALLOW_HOST = false; - ALLOW_REFLEXIVE = false; - ALLOW_RELAY = true; - } else { - ALLOW_HOST = true; - ALLOW_REFLEXIVE = true; - ALLOW_RELAY = true; - } - } - - private static final int MINIMUM_PORT = - 6112; // PORT (range +1000) to be used by ICE for communicating, each peer needs a seperate port - private static final long FORCE_SRFLX_RELAY_INTERVAL = - 2 * 60 * 1000; // 2 mins, the interval in which multiple connects have to happen to force srflx/relay - private static final int FORCE_SRFLX_COUNT = 1; - private static final int FORCE_RELAY_COUNT = 2; - - private final Peer peer; - - private Agent agent; - private IceMediaStream mediaStream; - private Component component; - - private volatile IceState iceState = NEW; - private volatile boolean connected = false; - private volatile Thread listenerThread; - - private PeerTurnRefreshModule turnRefreshModule; - - // Checks the connection by sending echo requests and initiates a reconnect if needed - private final PeerConnectivityCheckerModule connectivityChecker = new PeerConnectivityCheckerModule(this); - - // A list of the timestamps of initiated connectivity attempts, used to detect if relay/srflx should be forced - private final List connectivityAttemptTimes = new ArrayList<>(); - // How often have we been waiting for a response to local candidates/offer - private final AtomicInteger awaitingCandidatesEventId = new AtomicInteger(0); - - private final Lock lockInit = new ReentrantLock(); - private final Lock lockLostConnection = new ReentrantLock(); - private final Lock lockMessageReceived = new ReentrantLock(); - - public PeerIceModule(Peer peer) { - this.peer = peer; - } - - /** - * Updates the current iceState and informs the client via RPC - * - * @param newState the new State - */ - private void setState(IceState newState) { - this.iceState = newState; - rpcService.onIceConnectionStateChanged(IceAdapter.getId(), peer.getRemoteId(), iceState.getMessage()); - debug().peerStateChanged(this.peer); - } - - /** - * Will start the ICE Process - */ - void initiateIce() { - LockUtil.executeWithLock(lockInit, () -> { - if (peer.isClosing()) { - log.warn("{} Peer not connected anymore, aborting reinitiation of ICE", getLogPrefix()); - return; - } - - if (iceState != NEW && iceState != DISCONNECTED) { - log.warn( - "{} ICE already in progress, aborting re initiation. current state: {}", - getLogPrefix(), - iceState.getMessage()); - return; - } - - setState(GATHERING); - log.info("{} Initiating ICE for peer", getLogPrefix()); - - createAgent(); - gatherCandidates(); - }); - } - - /** - * Creates an agent and media stream for handling the ICE - */ - private void createAgent() { - if (agent != null) { - agent.free(); - } - - agent = new Agent(); - agent.setControlling(peer.isLocalOffer()); - - mediaStream = agent.createMediaStream("faData"); - } - - /** - * Gathers all local candidates, packs them into a message and sends them to the other peer via RPC - */ - private void gatherCandidates() { - log.info("{} Gathering ice candidates", getLogPrefix()); - - // For STUN all servers are relevant (latency is not an issue) - GameSession.getIceServers().stream() - .flatMap(s -> s.getStunAddresses().stream()) - .forEach(address -> { - log.info("{} Add STUN harvester for {}", getLogPrefix(), address.getHostName()); - agent.addCandidateHarvester(new StunCandidateHarvester(address)); - }); - - // TURN is latency sensitive - List iceServers = getViableIceServers(); - iceServers.forEach(iceServer -> iceServer.getTurnAddresses().forEach(address -> { - var harvester = new TurnCandidateHarvester( - address, new LongTermCredential(iceServer.getTurnUsername(), iceServer.getTurnCredential())); - log.info("{} Add TURN harvester for {}", getLogPrefix(), address.getHostName()); - agent.addCandidateHarvester(harvester); - })); - - CompletableFuture gatheringFuture = CompletableFuture.runAsync( - () -> { - try { - component = agent.createComponent( - mediaStream, - MINIMUM_PORT - + (int) (ThreadLocalRandom.current().nextDouble() * 999.0), - MINIMUM_PORT, - MINIMUM_PORT + 1000); - } catch (IOException e) { - throw new RuntimeException(e); - } - }, - IceAdapter.getExecutor()); - - CompletableFuture.runAsync( - () -> { - if (!gatheringFuture.isDone()) { - gatheringFuture.cancel(true); - } - }, - CompletableFuture.delayedExecutor(5000, TimeUnit.MILLISECONDS, IceAdapter.getExecutor())); - - try { - gatheringFuture.join(); - } catch (CompletionException e) { - // Completed exceptionally - log.error("{} Error while creating stream component/gathering candidates", getLogPrefix(), e); - CompletableFuture.runAsync(this::onConnectionLost, IceAdapter.getExecutor()); - return; - } catch (CancellationException e) { - // was cancelled due to timeout - log.error("{} Gathering candidates timed out", getLogPrefix(), e); - CompletableFuture.runAsync(this::onConnectionLost, IceAdapter.getExecutor()); - return; - } - - long previousConnectivityAttempts = getConnectivityAttempsInThePast(FORCE_SRFLX_RELAY_INTERVAL); - CandidatesMessage localCandidatesMessage = CandidateUtil.packCandidates( - IceAdapter.getId(), - peer.getRemoteId(), - agent, - component, - previousConnectivityAttempts < FORCE_SRFLX_COUNT && ALLOW_HOST, - previousConnectivityAttempts < FORCE_RELAY_COUNT && ALLOW_REFLEXIVE, - ALLOW_RELAY); - log.debug( - "{} Sending own candidates to {}, offered candidates: {}", - getLogPrefix(), - peer.getRemoteId(), - localCandidatesMessage.candidates().stream() - .map(it -> it.type().toString() + "(" + it.protocol() + ")") - .collect(Collectors.joining(", "))); - setState(AWAITING_CANDIDATES); - rpcService.onIceMsg(localCandidatesMessage); - - // Make sure to abort the connection process and reinitiate when we haven't received an answer to our offer in 6 - // seconds, candidate packet was probably lost - final int currentAwaitingCandidatesEventId = awaitingCandidatesEventId.incrementAndGet(); - CompletableFuture.runAsync( - () -> { - if (peer.isClosing()) { - log.warn( - "{} Peer {} not connected anymore, aborting reinitiation of ICE", - getLogPrefix(), - peer.getRemoteId()); - return; - } - if (iceState == AWAITING_CANDIDATES - && currentAwaitingCandidatesEventId == awaitingCandidatesEventId.get()) { - onConnectionLost(); - } - }, - CompletableFuture.delayedExecutor(6000, TimeUnit.MILLISECONDS, IceAdapter.getExecutor())); - } - - private List getViableIceServers() { - List allIceServers = GameSession.getIceServers(); - if (IceAdapter.getPingCount() <= 0 || allIceServers.isEmpty()) { - return allIceServers; - } - - // Try servers with acceptable latency - List viableIceServers = - allIceServers.stream().filter(IceServer::hasAcceptableLatency).collect(Collectors.toList()); - if (!viableIceServers.isEmpty()) { - log.info( - "Using all viable ice servers: {}", - viableIceServers.stream() - .map(it -> "[" - + it.getTurnAddresses().stream() - .map(TransportAddress::toString) - .collect(Collectors.joining(", ")) - + "]") - .collect(Collectors.joining(", "))); - return viableIceServers; - } - - log.info( - "Using all ice servers: {}", - allIceServers.stream() - .map(it -> "[" - + it.getTurnAddresses().stream() - .map(TransportAddress::toString) - .collect(Collectors.joining(", ")) - + "]") - .collect(Collectors.joining(", "))); - return allIceServers; - } - - /** - * Starts harvesting local candidates if in answer mode, then initiates the actual ICE process - * - * @param remoteCandidatesMessage - */ - public void onIceMessageReceived(CandidatesMessage remoteCandidatesMessage) { - LockUtil.executeWithLock(lockMessageReceived, () -> { - if (peer.isClosing()) { - log.warn("{} Peer not connected anymore, discarding ice message", getLogPrefix()); - return; - } - - // Start ICE async as it's blocking and this is the RPC thread - CompletableFuture.runAsync( - () -> { - log.debug( - "{} Got IceMsg for peer, offered candidates: {}", - getLogPrefix(), - remoteCandidatesMessage.candidates().stream() - .map(it -> it.type().toString() + "(" + it.protocol() + ")") - .collect(Collectors.joining(", "))); - - if (peer.isLocalOffer()) { - if (iceState != AWAITING_CANDIDATES) { - log.warn( - "{} Received candidates unexpectedly, current state: {}", - getLogPrefix(), - iceState.getMessage()); - return; - } - - } else { - // Check if we are already processing an ICE offer and if so stop it - if (iceState != NEW && iceState != DISCONNECTED) { - log.info("{} Received new candidates/offer, stopping...", getLogPrefix()); - onConnectionLost(); - } - - // Answer mode, initialize agent and gather candidates - initiateIce(); - } - - setState(CHECKING); - - long previousConnectivityAttempts = getConnectivityAttempsInThePast(FORCE_SRFLX_RELAY_INTERVAL); - CandidateUtil.unpackCandidates( - remoteCandidatesMessage, - agent, - component, - mediaStream, - previousConnectivityAttempts < FORCE_SRFLX_COUNT && ALLOW_HOST, - previousConnectivityAttempts < FORCE_RELAY_COUNT && ALLOW_REFLEXIVE, - ALLOW_RELAY); - - startIce(); - }, - IceAdapter.getExecutor()); - }); - } - - /** - * Runs the actual connectivity establishment, candidates have been exchanged and need to be checked - */ - private void startIce() { - connectivityAttemptTimes.add(0, System.currentTimeMillis()); - - log.debug("{} Starting ICE for peer {}", getLogPrefix(), peer.getRemoteId()); - agent.startConnectivityEstablishment(); - - // Wait for termination/completion of the agent - long iceStartTime = System.currentTimeMillis(); - while (!Thread.currentThread().isInterrupted() - && agent.getState() - != IceProcessingState - .COMPLETED) { // TODO include more?, maybe stop on COMPLETED, is that to early? - try { - Thread.sleep(20); - } catch (InterruptedException e) { - log.error("{} Interrupted while waiting for ICE", getLogPrefix(), e); - onConnectionLost(); - return; - } - - if (agent.getState() == IceProcessingState.FAILED) { // TODO null pointer due to no agent? - onConnectionLost(); - return; - } - - if (System.currentTimeMillis() - iceStartTime > 15_000) { - log.error("{} ABORTING ICE DUE TO TIMEOUT", getLogPrefix()); - onConnectionLost(); - return; - } - } - - log.debug( - "{} ICE terminated, connected, selected candidate pair: {} <-> {}", - getLogPrefix(), - component.getSelectedPair().getLocalCandidate().getType().toString(), - component.getSelectedPair().getRemoteCandidate().getType().toString()); - - // We are connected - connected = true; - rpcService.onConnected(IceAdapter.getId(), peer.getRemoteId(), true); - setState(CONNECTED); - - if (component.getSelectedPair().getLocalCandidate().getType() == CandidateType.RELAYED_CANDIDATE) { - turnRefreshModule = new PeerTurnRefreshModule( - this, (RelayedCandidate) component.getSelectedPair().getLocalCandidate()); - } - - if (peer.isLocalOffer()) { - connectivityChecker.start(); - } - - listenerThread = new Thread(this::listener); - listenerThread.start(); - } - - /** - * Connection has been lost, ice failed or we received a new offer - * Will close agent, stop listener and connectivity checker thread and change state to disconnected - * Will then reinitiate ICE - */ - public void onConnectionLost() { - LockUtil.executeWithLock(lockLostConnection, () -> { - if (iceState == DISCONNECTED) { - log.warn("{} Lost connection, albeit already in ice state disconnected", getLogPrefix()); - return; // TODO: will this kill the life cycle? - } - - IceState previousState = getIceState(); - - if (listenerThread != null) { - listenerThread.interrupt(); - listenerThread = null; - } - - if (turnRefreshModule != null) { - turnRefreshModule.close(); - turnRefreshModule = null; - } - - connectivityChecker.stop(); - - if (connected) { - connected = false; - log.warn("{} ICE connection has been lost for peer", getLogPrefix()); - rpcService.onConnected(IceAdapter.getId(), peer.getRemoteId(), false); - } - - setState(DISCONNECTED); - - if (agent != null) { - agent.free(); - agent = null; - mediaStream = null; - component = null; - } - - debug().peerStateChanged(this.peer); - - if (peer.isClosing()) { - log.warn("{} Peer not connected anymore, aborting onConnectionLost of ICE", getLogPrefix()); - return; - } - - if (peer.getGameSession().isGameEnded()) { - log.warn("{} GAME ENDED, ABORTING onConnectionLost of ICE for peer ", getLogPrefix()); - return; - } - - if (previousState == CONNECTED) { - TrayIcon.showMessage("Reconnecting to %s (connection lost)".formatted(this.peer.getRemoteLogin())); - } - - if (previousState == CONNECTED && peer.isLocalOffer()) { - // We were connected before, retry immediately - CompletableFuture.runAsync( - this::initiateIce, - CompletableFuture.delayedExecutor(0, TimeUnit.MILLISECONDS, IceAdapter.getExecutor())); - } else if (peer.isLocalOffer()) { - // Last ice attempt didn't succeed, so wait a bit - CompletableFuture.runAsync( - this::initiateIce, - CompletableFuture.delayedExecutor(5000, TimeUnit.MILLISECONDS, IceAdapter.getExecutor())); - } - }); - } - - /** - * Data received from FA, prepends prefix and sends it via ICE to the other peer - * - * @param faData - * @param length - */ - void onFaDataReceived(byte[] faData, int length) { - byte[] data = new byte[length + 1]; - data[0] = 'd'; - System.arraycopy(faData, 0, data, 1, length); - sendViaIce(data, 0, data.length); - } - - /** - * Send date via ice to the other peer - * - * @param data - * @param offset - * @param length - */ - void sendViaIce(byte[] data, int offset, int length) { - if (connected && component != null) { - try { - component - .getSelectedPair() - .getIceSocketWrapper() - .send(new DatagramPacket( - data, - offset, - length, - component - .getSelectedPair() - .getRemoteCandidate() - .getTransportAddress() - .getAddress(), - component - .getSelectedPair() - .getRemoteCandidate() - .getTransportAddress() - .getPort())); - } catch (IOException e) { - log.warn("{} Failed to send data via ICE", getLogPrefix(), e); - onConnectionLost(); - } catch (NullPointerException e) { - log.error("Component is null", e); - } - } - } - - /** - * Listens for data incoming via ice socket - */ - public void listener() { - log.debug("{} Now forwarding data from ICE to FA for peer", getLogPrefix()); - Component localComponent = component; - - byte[] data = new byte - [65536]; // 64KiB = UDP MTU, in practice due to ethernet frames being <= 1500 B, this is often not used - while (!Thread.currentThread().isInterrupted() && IceAdapter.getGameSession() == peer.getGameSession()) { - try { - DatagramPacket packet = new DatagramPacket(data, data.length); - localComponent - .getSelectedPair() - .getIceSocketWrapper() - .getUDPSocket() - .receive(packet); - - if (packet.getLength() == 0) { - continue; - } - - if (data[0] == 'd') { - // Received data - peer.onIceDataReceived(data, 1, packet.getLength() - 1); - } else if (data[0] == 'e') { - // Received echo req/res - if (peer.isLocalOffer()) { - connectivityChecker.echoReceived(data, 0, packet.getLength()); - } else { - sendViaIce(data, 0, packet.getLength()); // Turn around, send echo back - } - } else { - log.warn( - "{} Received invalid packet, first byte: 0x{}, length: {}", - getLogPrefix(), - data[0], - packet.getLength()); - } - - } catch (IOException e) { // TODO: nullpointer from localComponent.xxxx???? - log.warn("{} Error while reading from ICE adapter", getLogPrefix(), e); - if (component == localComponent) { - onConnectionLost(); - } - return; - } - } - - log.debug("{} No longer listening for messages from ICE", getLogPrefix()); - } - - void close() { - if (listenerThread != null) { - listenerThread.interrupt(); - listenerThread = null; - } - if (turnRefreshModule != null) { - turnRefreshModule.close(); - } - if (agent != null) { - agent.free(); - } - connectivityChecker.stop(); - } - - public long getConnectivityAttempsInThePast(final long millis) { - // copy list to avoid concurrency issues - return new ArrayList<>(connectivityAttemptTimes) - .stream() - .filter(time -> time > (System.currentTimeMillis() - millis)) - .count(); - } - - public String getLogPrefix() { - return "ICE %s:".formatted(peer.getPeerIdentifier()); - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerTurnRefreshModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerTurnRefreshModule.java deleted file mode 100644 index 5fe616a..0000000 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/PeerTurnRefreshModule.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.faforever.iceadapter.ice; - -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.time.Duration; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.ice4j.ice.RelayedCandidate; -import org.ice4j.ice.harvest.StunCandidateHarvest; -import org.ice4j.ice.harvest.TurnCandidateHarvest; -import org.ice4j.message.MessageFactory; -import org.ice4j.message.Request; -import org.ice4j.stack.TransactionID; - -/** - * Sends continuous refresh requests to the turn server - */ -@Slf4j -public class PeerTurnRefreshModule { - - private static final int REFRESH_INTERVAL = (int) Duration.ofMinutes(2).toMillis(); - - private static Field harvestField; - private static Method sendRequestMethod; - - static { - try { - harvestField = RelayedCandidate.class.getDeclaredField("turnCandidateHarvest"); - harvestField.setAccessible(true); - sendRequestMethod = StunCandidateHarvest.class.getDeclaredMethod( - "sendRequest", Request.class, boolean.class, TransactionID.class); - sendRequestMethod.setAccessible(true); - } catch (NoSuchFieldException | NoSuchMethodException e) { - log.error("Could not initialize harvestField for turn refreshing.", e); - } - } - - @Getter - private final PeerIceModule ice; - - @Getter - private final RelayedCandidate candidate; - - private TurnCandidateHarvest harvest = null; - - private Thread refreshThread; - private volatile boolean running = true; - - public PeerTurnRefreshModule(PeerIceModule ice, RelayedCandidate candidate) { - this.ice = ice; - this.candidate = candidate; - - try { - harvest = (TurnCandidateHarvest) harvestField.get(candidate); - } catch (IllegalAccessException e) { - log.error("Could not get harvest from candidate.", e); - } - - if (harvest != null) { - refreshThread = Thread.startVirtualThread(this::refreshThread); - - log.info("Started turn refresh module for peer {}", ice.getPeer().getRemoteLogin()); - } - } - - private void refreshThread() { - while (!Thread.currentThread().isInterrupted() && running) { - - Request refreshRequest = MessageFactory.createRefreshRequest( - 600); // Maximum lifetime of turn is 600 seconds (10 minutes), server may limit this even further - - try { - TransactionID transactionID = - (TransactionID) sendRequestMethod.invoke(harvest, refreshRequest, false, null); - - log.info("Sent turn refresh request."); - } catch (IllegalAccessException | InvocationTargetException e) { - log.error("Could not send turn refresh request!", e); - } - - try { - Thread.sleep(REFRESH_INTERVAL); - } catch (InterruptedException e) { - log.warn("Sleeping refreshThread was interrupted"); - return; - } - } - } - - public void close() { - running = false; - if (refreshThread != null) { - refreshThread.interrupt(); - refreshThread = null; - } - } -} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/IceAgentStrategy.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/IceAgentStrategy.java new file mode 100644 index 0000000..068455a --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/IceAgentStrategy.java @@ -0,0 +1,20 @@ +package com.faforever.iceadapter.ice.peer; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.ice4j.ice.NominationStrategy; + +@RequiredArgsConstructor +@Getter +public enum IceAgentStrategy { + FIRST("The first successful (Default)", NominationStrategy.NOMINATE_FIRST_VALID), + AUTO_PRIORITY("Auto-selection using priorities", NominationStrategy.NOMINATE_HIGHEST_PRIO), + FIRST_HOST_OR_REFLEXIVE("Preference for Host or Reflexive", NominationStrategy.NOMINATE_FIRST_HOST_OR_REFLEXIVE_VALID); + private final String name; + private final NominationStrategy strategy; + + @Override + public String toString() { + return name; + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/Peer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/Peer.java new file mode 100644 index 0000000..506e7c6 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/Peer.java @@ -0,0 +1,277 @@ +package com.faforever.iceadapter.ice.peer; + +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import com.faforever.iceadapter.ice.peer.modules.EventBusModule; +import com.faforever.iceadapter.util.CandidateUtil; +import kotlin.Pair; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.*; + +import java.net.DatagramSocket; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static com.faforever.iceadapter.debug.Debug.debug; + +/** + * Represents a peer in the current game session which we are connected to + */ +@Data +@Slf4j +@RequiredArgsConstructor +public class Peer { + private final int remoteId; + private final String remoteLogin; + private final boolean localOffer; // Do we offer or are we waiting for a remote offer + private final int preferredPort; + private final int lobbyPort; + private final Set disabledModules; + + private String peerIdentifier; + + private volatile long lastLostConnect = 0; + + private volatile float rtt = 0.0f; + private volatile Long lastEcho; + private volatile Long lastPacketReceived; + private AtomicInteger echosReceived = new AtomicInteger(0); + private AtomicInteger invalidPacket = new AtomicInteger(0); + + public volatile boolean closing = false; + + private volatile DatagramSocket faSocket; + + private volatile KeepAliveStrategy keepAliveStrategy; + private volatile Agent agent; + private volatile IceMediaStream mediaStream; + private volatile Component component; + private IceAgentStrategy agentStrategy = IceAgentStrategy.FIRST; + private AllowCombination combination = AllowCombination.ALL; + + private final AtomicInteger awaitingCandidatesEventId = new AtomicInteger(0); + private volatile IceState iceState = null; + + private final Map locks = new ConcurrentHashMap<>(); + private final Map modules = new ConcurrentHashMap<>(); + + public Integer getLocalPort() { + return faSocket != null ? faSocket.getLocalPort() : 0; + } + + public void init() { + peerIdentifier = "%s(%d)".formatted(remoteLogin, remoteId); + } + + public void initModules() { + PeerModule.getSortedModules() + .stream() + .filter(Predicate.not(disabledModules::contains)) + .forEach((module) -> { + modules.putIfAbsent(module, module.createModule(this)); + }); + } + + public void startModules() { + for (ModuleBase module : modules.values()) { + module.start(); + } + } + + public void stopModules() { + for (ModuleBase module : modules.values()) { + module.stop(); + } + } + + public boolean isConnected() { + return iceState == IceState.CONNECTED && component != null; + } + + public void startInitPeer() { + log.debug("Peer created: {}, localOffer: {}, preferredPort: {}", + getPeerIdentifier(), + localOffer, + preferredPort); + + setIceState(IceState.NEW); + } + + public void setIceState(IceState iceState) { + IceState old = this.iceState; + this.iceState = iceState; + event(bus -> bus.onIceStateChange(this, old, iceState)); + debug().peerStateChanged(this); + } + + public void setLastPacketReceived(Long lastPacketReceived) { + Long old = this.lastPacketReceived; + this.lastPacketReceived = lastPacketReceived; + event(bus -> bus.onLastPacketReceived(this, old, lastPacketReceived)); + } + + public void setIceStateWithoutTrigger(IceState iceState) { + this.iceState = iceState; + debug().peerStateChanged(this); + } + + public Lock getLock(String lockName) { + return locks.computeIfAbsent(lockName, k -> new ReentrantLock()); + } + + public Optional getModule(PeerModule module, Class type) { + ModuleBase foundModule = modules.get(module); + if (type != null && type.isInstance(foundModule)) { + return Optional.of(type.cast(foundModule)); + } else { + log.warn("Could not find module {} - {}", module, type); + return Optional.empty(); + } + } + + private void event(Consumer consumer) { + getEventBus().ifPresent(consumer); + } + + private Optional getEventBus() { + return getModule(PeerModule.EVENT_BUS, EventBusModule.class); + } + + public void addEventListener(PeerEventListener listener) { + getEventBus().ifPresent(bus -> bus.register(listener)); + } + + public void removeEventListener(PeerEventListener listener) { + getEventBus().ifPresent(bus -> bus.unregister(listener)); + } + + public void setAgent(Agent agent) { + this.agent = agent; + event(bus -> bus.onAgentChange(this, agent)); + } + + public void setMediaStream(IceMediaStream mediaStream) { + this.mediaStream = mediaStream; + event(bus -> bus.onIceMediaStreamChange(this, mediaStream)); + } + + public void setComponent(Component component) { + this.component = component; + event(bus -> bus.onIceComponentChange(this, component)); + } + + public CandidatePair getSelectedPair() { + return getActiveComponent() + .map(Component::getSelectedPair) + .orElse(null); + } + + public String getFullInfoSelectedPair() { + return CandidateUtil.infoCandidate(getSelectedPair()); + } + + public Optional getActiveComponent() { + return Optional.ofNullable(component); + } + + public Optional getActiveCandidatePair() { + return Optional.ofNullable(component).map(Component::getSelectedPair); + } + + public Collection getCandidatePairs() { + Collection pairs = new ArrayList<>(); + Optional.ofNullable(getSelectedPair()).ifPresent(pairs::add); + return pairs; + } + + public List> getCandidateTypes() { + List> candidates = new ArrayList<>(); + for (CandidatePair pair : getCandidatePairs()) { + candidates.add(new Pair<>(String.valueOf(pair.getLocalCandidate().getType()), String.valueOf(pair.getRemoteCandidate().getType()))); + } + + return candidates; + } + + public String getStrCandidateTypes(String delimiter) { + StringJoiner pairCandidates = new StringJoiner(delimiter); + for (Pair pair : getCandidateTypes()) { + pairCandidates.add("%s<->%s".formatted(pair.getFirst(), pair.getSecond())); + } + return pairCandidates.toString(); + } + + public IceState getState() { + return iceState; + } + + public Optional getAgentState() { + return Optional.ofNullable(agent) + .map(Agent::getState); + } + + public Optional getAverageRtt() { + return Optional.of(getRtt()); + } + + public Optional getLastReceived() { + return Optional.ofNullable(getLastPacketReceived()); + } + + public Integer countEchosReceived() { + return echosReceived.get(); + } + + public Integer countInvalidEchosReceived() { + return invalidPacket.get(); + } + + public void sendToFaSocket(byte[] data, int offset, int length) { + event(bus -> bus.onSendToFaSocket(this, data, offset, length)); + } + + public void sendToPeer(byte[] data, int offset, int length) { + event(bus -> bus.onSendToPeer(this, data, offset, length)); + } + + public void lostConnect() { + event(bus -> bus.onConnectionLost(this)); + } + + public void reconnect() { + lostConnect(); + } + + public void setLastEcho(long echo) { + Long lastEcho = this.lastEcho; + this.lastEcho = echo; + event(bus -> bus.onChangeEcho(this, lastEcho, echo)); + } + + public void close() { + if (closing) { + return; + } + + log.info("Closing peer for player {}", getPeerIdentifier()); + + closing = true; + event(bus -> bus.onClose(this, closing)); + for (ModuleBase module : modules.values()) { + module.stop(); + } + + log.info("Peer closed: {}", getPeerIdentifier()); + } + + +} + diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerEventListener.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerEventListener.java new file mode 100644 index 0000000..a093116 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerEventListener.java @@ -0,0 +1,38 @@ +package com.faforever.iceadapter.ice.peer; + +import com.faforever.iceadapter.ice.IceState; +import org.ice4j.ice.Agent; +import org.ice4j.ice.Component; +import org.ice4j.ice.IceMediaStream; + +public interface PeerEventListener { + default void onIceStateChange(Peer peer, IceState oldState, IceState newState) { + } + + default void onAgentChange(Peer peer, Agent agent) { + } + + default void onIceMediaStreamChange(Peer peer, IceMediaStream stream) { + } + + default void onIceComponentChange(Peer peer, Component component) { + } + + default void onLastPacketReceived(Peer peer, Long lastTimestamp, Long timestamp) { + } + + default void onChangeEcho(Peer peer, Long lastEcho, long echo) { + } + + default void onSendToFaSocket(Peer peer, byte[] data, int offset, int length) { + } + + default void onSendToPeer(Peer peer, byte[] data, int offset, int length) { + } + + default void onConnectionLost(Peer peer) { + } + + default void onClose(Peer peer, boolean hasClosed) { + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerModule.java new file mode 100644 index 0000000..992eb53 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/PeerModule.java @@ -0,0 +1,67 @@ +package com.faforever.iceadapter.ice.peer; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.modules.EventBusModule; +import com.faforever.iceadapter.ice.peer.modules.fa.FaToPeerModule; +import com.faforever.iceadapter.ice.peer.modules.fa.PeerToFaModule; +import com.faforever.iceadapter.ice.peer.modules.ice.PeerToPeerListenerModule; +import com.faforever.iceadapter.ice.peer.modules.ice.PeerToPeerSenderModule; +import com.faforever.iceadapter.ice.peer.modules.info.RttCalculateModule; +import com.faforever.iceadapter.ice.peer.modules.other.AutoSettingAllowCandidates; +import com.faforever.iceadapter.ice.peer.modules.other.ChangeIceStrategyModule; +import com.faforever.iceadapter.ice.peer.modules.other.FASocketModule; +import com.faforever.iceadapter.ice.peer.modules.other.PeerConnectivityCheckerModule; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +@RequiredArgsConstructor +@Getter +public enum PeerModule implements Comparator { + EVENT_BUS(0, EventBusModule::new), + FA_SOCKET_MODULE(1, peer -> { + var module = new FASocketModule(peer); + module.firstStart(); + return module; + }), + FA_SENDER_MODULE(PeerToFaModule::new), + FA_REPEATER_MODULE(peer -> { + var module = new FaToPeerModule(peer); + module.start(); + return module; + }), + CALCULATE_RTT(RttCalculateModule::new), + ICE_TO_ICE_SENDER(PeerToPeerSenderModule::new), + ICE_LISTENER_MODULE(PeerToPeerListenerModule::new), + CONNECTION_CHECKER_MODULE(PeerConnectivityCheckerModule::new), + AUTO_SETTING_ALLOW_CANDIDATE(AutoSettingAllowCandidates::new), + CHANGE_AGENT_STRATEGY(ChangeIceStrategyModule::new); + + @Getter + private static final List sortedModules = Stream.of(PeerModule.values()) + .sorted() + .toList(); + + private final int priority; + private final Function createModule; + + PeerModule(Function createModule) { + this(10, createModule); + } + + public ModuleBase createModule(Peer peer) { + ModuleBase module = createModule.apply(peer); + module.init(); + return module; + } + + @Override + public int compare(PeerModule o1, PeerModule o2) { + return Comparator.comparingInt(PeerModule::getPriority) + .thenComparing(PeerModule::getPriority).compare(o1, o2); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/AllowCombination.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/AllowCombination.java new file mode 100644 index 0000000..e8c363c --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/AllowCombination.java @@ -0,0 +1,16 @@ +package com.faforever.iceadapter.ice.peer.modules; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AllowCombination { + ALL(true, true, true), + REFLEXIVE_RELAY(false, true, true), + HOST_RELAY(true, false, true), + RELAY(false, false, true); + private final boolean allowHost; + private final boolean allowReflexive; + private final boolean allowRelay; +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/EventBusModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/EventBusModule.java new file mode 100644 index 0000000..53c4c3a --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/EventBusModule.java @@ -0,0 +1,112 @@ +package com.faforever.iceadapter.ice.peer.modules; + +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Agent; +import org.ice4j.ice.Component; +import org.ice4j.ice.IceMediaStream; + +import java.util.concurrent.CopyOnWriteArrayList; + +@Slf4j +@RequiredArgsConstructor +public class EventBusModule implements ModuleBase, PeerEventListener { + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + + private final Peer peer; + + public void register(PeerEventListener listener) { + listeners.add(listener); + log.debug("EventListener registered: {}", listener.getClass().getSimpleName()); + } + + public void unregister(PeerEventListener listener) { + listeners.remove(listener); + log.debug("EventListener unregistered: {}", listener.getClass().getSimpleName()); + } + + public void onIceStateChange(Peer peer, IceState oldState, IceState newState) { + log.info("Peer {} change iceState {} -> {}", peer.getPeerIdentifier(), oldState, newState); + listeners.forEach(l -> safeAsyncCall(l, "onIceStateChange", + () -> l.onIceStateChange(peer, oldState, newState))); + } + + public void onAgentChange(Peer peer, Agent newAgent) { + log.trace("Peer {} agent changed. now = {}", peer.getPeerIdentifier(), newAgent); + listeners.forEach(l -> safeAsyncCall(l, "onAgentChange", + () -> l.onAgentChange(peer, newAgent))); + } + + public void onIceMediaStreamChange(Peer peer, IceMediaStream stream) { + log.trace("Peer {} iceMediaStream changed. now = {}", peer.getPeerIdentifier(), stream); + listeners.forEach(l -> safeAsyncCall(l, "onIceMediaStreamChange", + () -> l.onIceMediaStreamChange(peer, stream))); + } + + public void onIceComponentChange(Peer peer, Component component) { + log.trace("Peer {} component changed. now = {}", peer.getPeerIdentifier(), component); + listeners.forEach(l -> safeAsyncCall(l, "onIceComponentChange", + () -> l.onIceComponentChange(peer, component))); + } + + @Override + public void onChangeEcho(Peer peer, Long lastEcho, long echo) { + listeners.forEach(l -> safeAsyncCall(l, "onChangeEcho", + () -> l.onChangeEcho(peer, lastEcho, echo))); + } + + public void onLastPacketReceived(Peer peer, Long lastTimestamp, Long timestamp) { + log.trace("Peer {} change lastPacketReceived {} -> {}", peer.getPeerIdentifier(), lastTimestamp, timestamp); + listeners.forEach(l -> safeAsyncCall(l, "onLastPacketReceived", + () -> l.onLastPacketReceived(peer, lastTimestamp, timestamp))); + } + + @Override + public void onSendToFaSocket(Peer peer, byte[] data, int offset, int length) { + log.trace("Peer {} onSendToFaSocket data with length {}", peer.getPeerIdentifier(), length); + listeners.forEach(l -> safeAsyncCall(l, "onSendToFaSocket", + () -> l.onSendToFaSocket(peer, data, offset, length))); + } + + @Override + public void onSendToPeer(Peer peer, byte[] data, int offset, int length) { + log.trace("Peer {} onSendToPeer data with length {}", peer.getPeerIdentifier(), length); + listeners.forEach(l -> safeAsyncCall(l, "onSendToPeer", + () -> l.onSendToPeer(peer, data, offset, length))); + } + + @Override + public void onConnectionLost(Peer peer) { + log.info("Peer onConnectionLost: {}", peer.getPeerIdentifier()); + listeners.forEach(l -> safeAsyncCall(l, "onConnectionLost", () -> l.onConnectionLost(peer))); + } + + @Override + public void onClose(Peer peer, boolean hasClosed) { + log.info("Peer closed: {}", peer.getPeerIdentifier()); + listeners.forEach(l -> safeAsyncCall(l, "onClose", () -> l.onClose(peer, hasClosed))); + } + + private void safeAsyncCall(PeerEventListener listener, String methodName, Runnable call) { + asyncVirtual(methodName, peer, call); + } + + private void asyncVirtual(String methodName, Peer peer, Runnable call) { + Thread.ofVirtual() + .name(methodName + "|" + peer.getPeerIdentifier()) + .start(call); + } + + @Override + public void stop() { + if (!peer.isClosing()) { + return; + } + + listeners.clear(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/FaToPeerModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/FaToPeerModule.java new file mode 100644 index 0000000..6ce16d7 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/FaToPeerModule.java @@ -0,0 +1,130 @@ +package com.faforever.iceadapter.ice.peer.modules.fa; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.util.LockUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.SocketException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static com.faforever.iceadapter.util.DatagramSocketUtils.MAX_SIZE_PACKET; + +@Slf4j +@RequiredArgsConstructor +public class FaToPeerModule implements ModuleBase { + public static final char COMMAND_FA = 'd'; + private static final String LOCK_MODULE = "FAListenerModule"; + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + + private final Peer peer; + private volatile Future listener; + + @Override + public void start() { + LockUtil.executeWithLock(peer.getLock(LOCK_MODULE), this::startListeners); + } + + @Override + public void stop() { + LockUtil.executeWithLock(peer.getLock(LOCK_MODULE), this::stopListeners); + } + + private void startListeners() { + if (listener == null || + listener.isDone()) { + listener = executor.submit(this::faListener); + } + } + + private void stopListeners() { + if (peer.isClosing() && listener != null && !listener.isDone()) { + listener.cancel(true); + listener = null; + } + } + + /** + * This method get's invoked by the thread listening for data from FA + */ + private void faListener() { + while (!peer.isClosing()) { + DatagramSocket socket = peer.getFaSocket(); + if (socket != null) { + receiveCatch(socket); + } else { + log.error("Socket is null. Receive from FA skipped"); + break; + } + } + log.debug("No longer listening for messages from FA for peer"); + } + + private void receiveCatch(DatagramSocket socket) { + try { + receive(socket); + } catch (SocketException se) { + // socket closed or network error + if (peer.isClosing()) { + log.debug("FA listener shutting down for peer: {}", se.toString()); + } else { + log.warn("SocketException in FA listener for peer: {}", se.toString()); + // Try to trigger ICE reconnect safely + try { + peer.lostConnect(); + } catch (Exception ex) { + log.debug("Error while requesting ICE reconnect after socket exception", ex); + } + } + } catch (IOException e) { + if (peer.isClosing()) { + log.debug( + "Ignoring error while receiving packet because the connection was closed as peer"); + } else { + log.debug("Error while reading from local FA as peer (probably disconnecting from peer)", e); + try { + peer.lostConnect(); + } catch (Exception ex) { + log.debug("Error while requesting ICE reconnect after IO error", ex); + } + } + } + } + + private void receive(DatagramSocket socket) throws IOException { + if (!peer.isConnected()) { + return; + } + byte[] data = new byte[MAX_SIZE_PACKET]; + DatagramPacket packet = new DatagramPacket(data, data.length); + socket.receive(packet); + if (packet.getLength() == 0) { + return; + } + // Defensive copy of payload to avoid races with the receive buffer + byte[] copy = new byte[packet.getLength()]; + System.arraycopy(packet.getData(), packet.getOffset(), copy, 0, packet.getLength()); + + // Forward to ICE - this method will drop packets if ICE isn't ready + onFaDataReceived(copy); + } + + /** + * Data received from FA, prepends prefix and sends it via ICE to the other peer + * + * @param faData + */ + void onFaDataReceived(byte[] faData) { + int length = faData.length; + byte[] data = new byte[length + 1]; + data[0] = COMMAND_FA; + System.arraycopy(faData, 0, data, 1, length); + peer.sendToPeer(data, 0, data.length); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/PeerToFaModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/PeerToFaModule.java new file mode 100644 index 0000000..2e2b449 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/fa/PeerToFaModule.java @@ -0,0 +1,63 @@ +package com.faforever.iceadapter.ice.peer.modules.fa; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.UnknownHostException; + +@Slf4j +@RequiredArgsConstructor +public class PeerToFaModule implements ModuleBase, PeerEventListener { + private static final String LOCALHOST = "127.0.0.1"; + + private final Peer peer; + + @Override + public void init() { + peer.addEventListener(this); + } + + @Override + public void onSendToFaSocket(Peer peer, byte[] data, int offset, int length) { + getSocketAndTrySend(data, offset, length); + } + + private void getSocketAndTrySend(byte[] data, int offset, int length) { + DatagramSocket socket = peer.getFaSocket(); + if (socket == null) { + log.error("Socket is null. Send to FA skipped"); + return; + } + send(socket, data, offset, length); + } + + private void send(DatagramSocket socket, byte[] data, int offset, int length) { + try { + DatagramPacket packet = new DatagramPacket(data, offset, length, InetAddress.getByName(LOCALHOST), peer.getLobbyPort()); + socket.send(packet); + } catch (UnknownHostException e) { + // should never happen for 127.0.0.1 but log at debug if it does + log.debug("UnknownHostException when forwarding to FA for {}", peer.getPeerIdentifier(), e); + } catch (IOException e) { + if (peer.isClosing()) { + log.debug( + "Ignoring error while sending packet because the connection was closed {}", peer.getPeerIdentifier()); + } else { + log.error( + "Error while writing to local FA as peer (probably disconnecting from peer) {}", + peer.getPeerIdentifier(), + e); + peer.lostConnect(); + } + } + } + + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerListenerModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerListenerModule.java new file mode 100644 index 0000000..29d1a4b --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerListenerModule.java @@ -0,0 +1,134 @@ +package com.faforever.iceadapter.ice.peer.modules.ice; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import com.faforever.iceadapter.ice.peer.modules.fa.FaToPeerModule; +import com.faforever.iceadapter.util.DatagramSocketUtils; +import com.faforever.iceadapter.util.LockUtil; +import com.google.common.primitives.Longs; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Component; +import org.ice4j.socket.MultiplexingDatagramSocket; + +import java.net.DatagramPacket; +import java.util.Arrays; +import java.util.concurrent.locks.Lock; + +import static com.faforever.iceadapter.ice.peer.modules.other.PeerConnectivityCheckerModule.COMMAND_ECHO; + +@Slf4j +@RequiredArgsConstructor +public class PeerToPeerListenerModule implements ModuleBase, PeerEventListener { + private static final String LOCK_SOCKET = "PeerToPeerSocket"; + + private final Peer peer; + private volatile boolean running = false; + private Lock lockSocket; + + @Override + public void init() { + peer.addEventListener(this); + lockSocket = peer.getLock(LOCK_SOCKET); + } + + @Override + public void onIceComponentChange(Peer peer, Component component) { + if (component == null) { + stop(); + return; + } + + log.info("Create ice listener"); + Thread.ofVirtual().name(threadComponentListenerName()).start(() -> LockUtil.executeWithLock(lockSocket, () -> createListener(component))); + } + + private String threadComponentListenerName() { + return "ComponentListener-%s".formatted(peer.getPeerIdentifier()); + } + + public void stop() { + if (running) { + log.info("Stopping IceListenerModule"); + running = false; + // Прерываем receive() через закрытие сокета или thread.interrupt() + Component component = peer.getComponent(); // предположим, есть такой метод + if (component != null) { + try { + component.getSocket().close(); + } catch (Exception e) { + log.debug("Error closing socket during stop", e); + } + } + } + } + + + private void createListener(Component component) { + running = true; + MultiplexingDatagramSocket datagramSocket = component.getSocket(); + byte[] buf = new byte[DatagramSocketUtils.MAX_SIZE_PACKET]; + while (!datagramSocket.isClosed()) { + try { + DatagramPacket packet = new DatagramPacket(buf, buf.length); + datagramSocket.receive(packet); + log.trace("Receive from {} {}", peer.getPeerIdentifier(), packet.getLength()); + if (packet.getLength() == 0) { + return; + } + + // We copy the buffer so that we can reuse it next time. + byte[] dataCopy = new byte[packet.getLength()]; + System.arraycopy(packet.getData(), packet.getOffset(), dataCopy, 0, packet.getLength()); + + Thread.ofVirtual() + .name(threadComponentListenerName()) + .start(() -> handlerData(peer, dataCopy, 0, dataCopy.length)); + } catch (Exception e) { + if (peer.isClosing()) { + break; + } + if (!datagramSocket.isClosed()) { + log.error("Ice Listener error", e); + } + peer.lostConnect(); + break; + } + } + log.info("Ice Listener closed"); + running = false; + } + + private void handlerData(Peer peer, byte[] data, int offset, int length) { + peer.setLastPacketReceived(System.currentTimeMillis()); + + + if (data[0] == FaToPeerModule.COMMAND_FA) { + peer.sendToFaSocket(data, 1, length - 1); + } else if (data[0] == COMMAND_ECHO) { + if (!peer.isLocalOffer()) { + peer.sendToPeer(data, offset, length); + } + if (length == 9) { + peer.setLastEcho(Longs.fromByteArray(Arrays.copyOfRange(data, 1, length))); + peer.getEchosReceived().incrementAndGet(); + } else { + peer.getInvalidPacket().incrementAndGet(); + log.error("Invalid Echo received. length={}", length); + } + + } else if (DatagramSocketUtils.isStunPacket(data, length)) { + int type = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF); + log.debug("STUN-like packet received, type: 0x{}, length: {}", String.format("%04X", type), length); + } else { + peer.getInvalidPacket().incrementAndGet(); + log.warn("Received invalid packet, first byte: 0x{}, length: {}, data (hex): {}", + String.format("%02X", data[0]), + length, + DatagramSocketUtils.bytesToHex(Arrays.copyOf(data, Math.min(length, 16))) + ); + } + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerSenderModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerSenderModule.java new file mode 100644 index 0000000..6da2cf8 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/ice/PeerToPeerSenderModule.java @@ -0,0 +1,65 @@ +package com.faforever.iceadapter.ice.peer.modules.ice; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import com.faforever.iceadapter.util.LockUtil; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Component; + +import java.util.concurrent.locks.Lock; + +@Slf4j +@RequiredArgsConstructor +public class PeerToPeerSenderModule implements ModuleBase, PeerEventListener { + private static final String LOCK_COMPONENT = "LockComponent"; + + private final Peer peer; + + @Setter + private Component component; + private Lock lockComponent; + + @Override + public void init() { + peer.addEventListener(this); + lockComponent = peer.getLock(LOCK_COMPONENT); + } + + @Override + public void stop() { + LockUtil.executeWithLock(lockComponent, () -> setComponent(null)); + } + + @Override + public void onIceComponentChange(Peer peer, Component component) { + LockUtil.executeWithLock(lockComponent, () -> setComponent(component)); + } + + @Override + public void onSendToPeer(Peer peer, byte[] data, int offset, int length) { + Component currentComponent = LockUtil.executeWithLock(lockComponent, () -> this.component); + if (currentComponent == null) { + log.warn("Cannot send: component is null"); + return; + } + send(currentComponent, data, offset, length); + } + + private void send(Component component, byte[] data, int offset, int length) { + if (peer.isClosing()) { + return; + } + try { + component.send(data, offset, length); + log.trace("Send to {} {} {}", peer.getPeerIdentifier(), offset, length); + } catch (Exception e) { + if (!peer.isClosing()) { + log.error("Send failed", e); + peer.lostConnect(); + } + } + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/info/RttCalculateModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/info/RttCalculateModule.java new file mode 100644 index 0000000..e5991fd --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/info/RttCalculateModule.java @@ -0,0 +1,43 @@ +package com.faforever.iceadapter.ice.peer.modules.info; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class RttCalculateModule implements ModuleBase, PeerEventListener { + + private final Peer peer; + + @Override + public void init() { + peer.addEventListener(this); + } + + @Override + public void start() { + peer.setRtt(0.0f); + } + + @Override + public void onChangeEcho(Peer peer, Long lastEcho, long echo) { + if (peer.isLocalOffer()) { + calculateRttWhenAgentControlling(echo); + } + } + + private void calculateRttWhenAgentControlling(long echo) { + long rttMs = System.currentTimeMillis() - echo; + int rtt = (int) (rttMs); + calculateRtt(rtt); + } + + private void calculateRtt(int rtt) { + float oldRtt = peer.getRtt(); + float calcRtt = oldRtt == 0 ? rtt : oldRtt * 0.8f + (float) rtt * 0.2f; + peer.setRtt(calcRtt); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/AutoSettingAllowCandidates.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/AutoSettingAllowCandidates.java new file mode 100644 index 0000000..906941c --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/AutoSettingAllowCandidates.java @@ -0,0 +1,44 @@ +package com.faforever.iceadapter.ice.peer.modules.other; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Agent; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@RequiredArgsConstructor +public class AutoSettingAllowCandidates implements ModuleBase, PeerEventListener { + private static final List combinations = List.of(AllowCombination.values()); + + private final Peer peer; + + private final AtomicInteger index = new AtomicInteger(0); + + @Override + public void init() { + peer.addEventListener(this); + } + + @Override + public void onAgentChange(Peer peer, Agent agent) { + if (agent != null) { + changeCombination(); + } + } + + private void changeCombination() { + int id = index.getAndIncrement(); + if (id >= combinations.size()) { + index.set(0); + id = 0; + } + AllowCombination combination = combinations.get(id); + peer.setCombination(combination); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/ChangeIceStrategyModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/ChangeIceStrategyModule.java new file mode 100644 index 0000000..18ade2c --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/ChangeIceStrategyModule.java @@ -0,0 +1,43 @@ +package com.faforever.iceadapter.ice.peer.modules.other; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Agent; + +@Slf4j +@RequiredArgsConstructor +public class ChangeIceStrategyModule implements ModuleBase, PeerEventListener { + + private final Peer peer; + + @Setter + private boolean enabled = true; + + @Override + public void init() { + if (isStartCondition()) { + peer.addEventListener(this); + } + } + + private boolean isStartCondition() { + return enabled && peer.isLocalOffer(); + } + + @Override + public void onAgentChange(Peer peer, Agent agent) { + if (peer.isClosing() || !enabled) { + return; + } + + if (agent == null) { + return; + } + agent.setNominationStrategy(peer.getAgentStrategy().getStrategy()); + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/FASocketModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/FASocketModule.java new file mode 100644 index 0000000..fa69bcc --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/FASocketModule.java @@ -0,0 +1,82 @@ +package com.faforever.iceadapter.ice.peer.modules.other; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.util.DatagramSocketUtils; +import com.faforever.iceadapter.util.LockUtil; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.net.DatagramSocket; +import java.net.SocketException; + +@Slf4j +@RequiredArgsConstructor +public class FASocketModule implements ModuleBase { + private static final String LOCK_MODULE = "FASocketModule"; + + private final Peer peer; + + public void firstStart() { + LockUtil.executeWithLock(peer.getLock(LOCK_MODULE), () -> { + try { + peer.setFaSocket(initForwarding(peer.getPreferredPort(), peer.getLocalPort())); + } catch (Exception e) { + log.error("Could not connect to FA for {}", peer.getPeerIdentifier(), e); + } + }); + } + + @Override + public void start() { + LockUtil.executeWithLock(peer.getLock(LOCK_MODULE), this::checkSocket); + } + + @Override + public void stop() { + LockUtil.executeWithLock(peer.getLock(LOCK_MODULE), this::stopSocket); + } + + @SneakyThrows(SocketException.class) + private DatagramSocket initForwarding(int port, Integer localPort) { + try { + if (localPort != null) { + port = localPort; + } + DatagramSocket socket = new DatagramSocket(port); + DatagramSocketUtils.resizeBuffer(socket); + log.debug("Now forwarding data to peer {} on port {}", peer.getPeerIdentifier(), peer.getLocalPort()); + return socket; + } catch (SocketException e) { + log.error("Could not create socket for peer: {}", peer.getPeerIdentifier(), e); + throw e; + } + } + + private void checkSocket() { + if (peer.isClosing()) { + return; + } + DatagramSocket socket = peer.getFaSocket(); + if (socket == null || socket.isClosed()) { + log.error("Socket {} is null or closed", peer.getPeerIdentifier()); + try { + log.warn("Trying to connect to FA for {}", peer.getPeerIdentifier()); + socket = initForwarding(peer.getPreferredPort(), peer.getLocalPort()); + peer.setFaSocket(socket); + } catch (Exception e) { + log.error("Could not connect to FA for {}", peer.getPeerIdentifier(), e); + } + } + } + + private void stopSocket() { + DatagramSocket socket = peer.getFaSocket(); + if (peer.isClosing() && socket != null && !socket.isClosed()) { + socket.close(); + peer.setFaSocket(null); + } + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/PeerConnectivityCheckerModule.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/PeerConnectivityCheckerModule.java new file mode 100644 index 0000000..89c455c --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ice/peer/modules/other/PeerConnectivityCheckerModule.java @@ -0,0 +1,113 @@ +package com.faforever.iceadapter.ice.peer.modules.other; + +import com.faforever.iceadapter.ice.ModuleBase; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import com.faforever.iceadapter.util.LockUtil; +import com.google.common.primitives.Longs; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import static com.faforever.iceadapter.debug.Debug.debug; + +/** + * Periodically sends echo requests via the ICE data channel and initiates a reconnect after timeout + * ONLY THE OFFERING ADAPTER of a connection will send echos and reoffer. + */ +@Slf4j +@RequiredArgsConstructor +public class PeerConnectivityCheckerModule implements ModuleBase, PeerEventListener { + + public static final char COMMAND_ECHO = 'e'; + private static final String LOCK_CHECKER_MODULE = "PeerConnectivityCheckerModule"; + + private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); + + private static final int ECHO_INTERVAL = 1000; + private static final int TIMEOUT_BEFORE_LOST_CONNECT = 10000; + private final Peer peer; + + private ScheduledFuture scheduledFuture; + + @Override + public void init() { + peer.addEventListener(this); + } + + @Override + public void start() { + if (!peer.isLocalOffer()) { + return; + } + peer.setLastPacketReceived(System.currentTimeMillis()); + LockUtil.executeWithLock(peer.getLock(LOCK_CHECKER_MODULE), () -> { + if (peer.isClosing()) { + return; + } + if (isRunning()) { + return; + } + + log.debug("Starting connectivity checker for peer"); + scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(this::checkerThread, + 0, + ECHO_INTERVAL, + TimeUnit.MILLISECONDS); + }); + } + + @Override + public Boolean isRunning() { + return scheduledFuture != null && !scheduledFuture.isDone(); + } + + private String getThreadName() { + return "connectivityChecker-%s".formatted(peer.getPeerIdentifier()); + } + + @Override + public void stop() { + if (!peer.isLocalOffer()) { + return; + } + + LockUtil.executeWithLock(peer.getLock(LOCK_CHECKER_MODULE), () -> { + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + scheduledFuture = null; + } + }); + } + + private void checkerThread() { + if (!peer.isConnected()) { + return; + } + Thread.currentThread().setName(getThreadName()); + log.trace("Running connectivity checker"); + + byte[] data = new byte[9]; + data[0] = COMMAND_ECHO; + + // Copy current time (long, 8 bytes) into array after leading prefix indicating echo + System.arraycopy(Longs.toByteArray(System.currentTimeMillis()), 0, data, 1, 8); + + peer.sendToPeer(data, 0, data.length); + + long lastPacketReceived = peer.getLastPacketReceived(); + long sinceLastReal = System.currentTimeMillis() - lastPacketReceived; + + if (sinceLastReal > TIMEOUT_BEFORE_LOST_CONNECT) { + log.warn("{} No traffic (echo or game) for {} ms (> {} ms timeout). Closing connection.", + peer.getPeerIdentifier(), lastPacketReceived, TIMEOUT_BEFORE_LOST_CONNECT); + peer.lostConnect(); + return; + } + debug().peerConnectivityUpdate(peer); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCHandler.java b/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCHandler.java index d3f8c7a..0dfd5f6 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCHandler.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCHandler.java @@ -4,19 +4,13 @@ import com.faforever.iceadapter.IceAdapter; import com.faforever.iceadapter.IceStatus; import com.faforever.iceadapter.gpgnet.GPGNetServer; +import com.faforever.iceadapter.gpgnet.GameState; import com.faforever.iceadapter.gpgnet.LobbyInitMode; import com.faforever.iceadapter.ice.CandidatesMessage; import com.faforever.iceadapter.ice.GameSession; -import com.faforever.iceadapter.ice.Peer; +import com.faforever.iceadapter.ice.peer.Peer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -24,7 +18,13 @@ import org.ice4j.ice.Candidate; import org.ice4j.ice.CandidatePair; import org.ice4j.ice.CandidateType; -import org.ice4j.ice.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; /** * Handles calls from JsonRPC (the client) @@ -61,27 +61,40 @@ public void setLobbyInitMode(String lobbyInitMode) { } public void iceMsg(long remotePlayerId, Object msg) { + log.info("IceMsg received {}", msg); boolean err = true; + CandidatesMessage message; + try { + message = objectMapper.readValue((String) msg, CandidatesMessage.class); + } catch (Exception e) { + log.error("Failed to parse iceMsg {}", msg, e); + return; + } + int idFrom = message.srcId(); + int idTo = message.destId(); + int myId = IceAdapter.getId(); + if (myId != idTo) { + log.error("The iceMsg {} is not meant for {}. IceMsg ignored", message, idTo); + return; + } - GameSession gameSession = IceAdapter.getGameSession(); - if (gameSession != null) { // This is highly unlikely, game session got created if JoinGame/HostGame came first - Peer peer = gameSession.getPeers().get((int) remotePlayerId); - if (peer != null) { // This is highly unlikely, peer is present if connectToPeer was called first - try { - peer.getIce().onIceMessageReceived(objectMapper.readValue((String) msg, CandidatesMessage.class)); - err = false; - } catch (IOException e) { - log.error("Failed to parse iceMsg {}", msg, e); - return; - } - } + if (remotePlayerId != idFrom) { + log.error("The sender {} != {} does not match the IceMsg source. IceMsg ignored", remotePlayerId, idFrom); + return; } - if (err) { - log.error("ICE MESSAGE IGNORED for id: {}", remotePlayerId); + GameSession gameSession = IceAdapter.getGameSessionSafe(); + if (gameSession == null) { + log.error("The gameSession is null. IceMsg ignored. {}", message); + return; } - log.info("IceMsg received {}", msg); + Peer peer = gameSession.getPeers().get((int) remotePlayerId); + if (peer == null) { + log.error("Peer not found for id: {}. IceMsg ignored. {}", remotePlayerId, message); + return; + } + gameSession.onIceMessageFromRPC(peer, message); } public void sendToGpgNet(String header, Object... args) { @@ -96,43 +109,36 @@ public void setIceServers(List> iceServers) { @SneakyThrows public String status() { IceStatus.IceGPGNetState gpgpnet = new IceStatus.IceGPGNetState( - gpgNetServer.getGpgnetPort(), gpgNetServer.isConnected(), gpgNetServer.getGameStateString(), "-"); + gpgNetServer.getStaticGpgNetPort(), gpgNetServer.isConnected(), gpgNetServer.getGameState().orElse(GameState.NONE).getName(), "-"); List relays = new ArrayList<>(); - GameSession gameSession = IceAdapter.getGameSession(); + GameSession gameSession = IceAdapter.getGameSessionSafe(); if (gameSession != null) { lockStatus.lock(); try { gameSession.getPeers().values().stream() .map(peer -> { + Optional pair = peer.getActiveCandidatePair(); IceStatus.IceRelay.IceRelayICEState iceRelayICEState = new IceStatus.IceRelay.IceRelayICEState( peer.isLocalOffer(), - peer.getIce().getIceState().getMessage(), + peer.getIceState().getMessage(), "", "", - peer.getIce().isConnected(), - Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getLocalCandidate) + peer.isConnected(), + pair.map(CandidatePair::getLocalCandidate) .map(Candidate::getHostAddress) .map(TransportAddress::toString) .orElse(""), - Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getRemoteCandidate) + pair.map(CandidatePair::getRemoteCandidate) .map(Candidate::getHostAddress) .map(TransportAddress::toString) .orElse(""), - Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getLocalCandidate) + pair.map(CandidatePair::getLocalCandidate) .map(Candidate::getType) .map(CandidateType::toString) .orElse(""), - Optional.ofNullable(peer.getIce().getComponent()) - .map(Component::getSelectedPair) - .map(CandidatePair::getRemoteCandidate) + pair.map(CandidatePair::getRemoteCandidate) .map(Candidate::getType) .map(CandidateType::toString) .orElse(""), @@ -141,7 +147,7 @@ public String status() { return new IceStatus.IceRelay( peer.getRemoteId(), peer.getRemoteLogin(), - peer.getFaSocket().getLocalPort(), + peer.getLocalPort(), iceRelayICEState); }) .forEach(relays::add); @@ -152,14 +158,11 @@ public String status() { IceStatus status = new IceStatus( IceAdapter.getVersion(), - GameSession.getIceServers().stream() - .mapToInt(s -> s.getTurnAddresses().size() - + s.getStunAddresses().size()) - .sum(), - gpgNetServer.getLobbyPort(), + GameSession.getAllServers().size(), + gpgNetServer.getStaticLobbyPort(), gpgNetServer.getLobbyInitMode().getName(), new IceStatus.IceOptions( - IceAdapter.getId(), IceAdapter.getLogin(), rpcPort, gpgNetServer.getGpgnetPort()), + IceAdapter.getId(), IceAdapter.getLogin(), rpcPort, gpgNetServer.getStaticGpgNetPort()), gpgpnet, relays.toArray(new IceStatus.IceRelay[relays.size()])); diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCService.java b/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCService.java index f81748a..5934863 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCService.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/rpc/RPCService.java @@ -1,34 +1,42 @@ package com.faforever.iceadapter.rpc; -import static com.faforever.iceadapter.debug.Debug.debug; - import com.faforever.iceadapter.FafRpcCallbacks; import com.faforever.iceadapter.debug.Debug; -import com.faforever.iceadapter.debug.InfoWindow; import com.faforever.iceadapter.gpgnet.GPGNetServer; import com.faforever.iceadapter.gpgnet.GameState; import com.faforever.iceadapter.ice.CandidatesMessage; +import com.faforever.iceadapter.ui.InfoWindow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.nbarraille.jjsonrpc.JJsonPeer; import com.nbarraille.jjsonrpc.TcpServer; -import java.util.Arrays; -import java.util.List; +import lombok.Data; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.List; + +import static com.faforever.iceadapter.debug.Debug.debug; + /** * Handles communication between client and adapter, opens a server for the client to connect to */ @Slf4j +@RequiredArgsConstructor +@Data public class RPCService implements AutoCloseable { + private static final String NOT_CONNECTION = "N/A"; private static final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private static TcpServer tcpServer; private static volatile boolean skipRPCMessages = false; - public void init(int port, GPGNetServer gpgNetServer, FafRpcCallbacks callbacks) { - Debug.RPC_PORT = port; + private final int port; + private String host; + + public void init(GPGNetServer gpgNetServer, FafRpcCallbacks callbacks) { + host = NOT_CONNECTION; log.info("Creating RPC server on port {}", port); RPCHandler rpcHandler = new RPCHandler(port, callbacks, gpgNetServer); @@ -37,7 +45,9 @@ public void init(int port, GPGNetServer gpgNetServer, FafRpcCallbacks callbacks) debug().rpcStarted(tcpServer.getFirstPeer()); tcpServer.getFirstPeer().thenAccept(firstPeer -> { + host = String.valueOf(firstPeer.getSocket().getInetAddress()); firstPeer.onConnectionLost(() -> { + host = NOT_CONNECTION; GameState gameState = gpgNetServer.getGameState().orElse(null); if (gameState == GameState.LAUNCHING) { skipRPCMessages = true; @@ -46,11 +56,9 @@ public void init(int port, GPGNetServer gpgNetServer, FafRpcCallbacks callbacks) Debug.ENABLE_INFO_WINDOW = true; Debug.init(); } - InfoWindow.INSTANCE.show(); + InfoWindow.INSTANCE.showWindow(); } else { - log.info( - "Lost connection to first RPC Peer. GameState: {}, Stopping adapter...", - gameState.getName()); + log.info("Lost connection to first RPC Peer. GameState: {}, Stopping adapter...", gameState); callbacks.close(); } }); @@ -59,13 +67,13 @@ public void init(int port, GPGNetServer gpgNetServer, FafRpcCallbacks callbacks) public void onConnectionStateChanged(String newState) { if (!skipRPCMessages) { - getPeerOrWait().sendNotification("onConnectionStateChanged", Arrays.asList(newState)); + getPeerOrWait().sendNotification("onConnectionStateChanged", List.of(newState)); } } public void onGpgNetMessageReceived(String header, List chunks) { if (!skipRPCMessages) { - getPeerOrWait().sendNotification("onGpgNetMessageReceived", Arrays.asList(header, chunks)); + getPeerOrWait().sendNotification("onGpgNetMessageReceived", List.of(header, chunks)); } } @@ -75,10 +83,7 @@ public void onIceMsg(CandidatesMessage candidatesMessage) { getPeerOrWait() .sendNotification( "onIceMsg", - Arrays.asList( - candidatesMessage.srcId(), - candidatesMessage.destId(), - objectMapper.writeValueAsString(candidatesMessage))); + List.of(candidatesMessage.srcId(), candidatesMessage.destId(), objectMapper.writeValueAsString(candidatesMessage))); } catch (JsonProcessingException e) { throw new RuntimeException(e); } @@ -89,13 +94,13 @@ public void onIceConnectionStateChanged(long localPlayerId, long remotePlayerId, if (!skipRPCMessages) { getPeerOrWait() .sendNotification( - "onIceConnectionStateChanged", Arrays.asList(localPlayerId, remotePlayerId, state)); + "onIceConnectionStateChanged", List.of(localPlayerId, remotePlayerId, state)); } } public void onConnected(long localPlayerId, long remotePlayerId, boolean connected) { if (!skipRPCMessages) { - getPeerOrWait().sendNotification("onConnected", Arrays.asList(localPlayerId, remotePlayerId, connected)); + getPeerOrWait().sendNotification("onConnected", List.of(localPlayerId, remotePlayerId, connected)); } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/ConnectService.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/ConnectService.java new file mode 100644 index 0000000..c57bd7b --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/ConnectService.java @@ -0,0 +1,14 @@ +package com.faforever.iceadapter.services; + +import com.faforever.iceadapter.ice.CandidatesMessage; +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.peer.Peer; + +public interface ConnectService { + + void onChangeIceState(Peer peer, IceState oldState, IceState iceState); + + void onConnectionLost(Peer peer); + + void onMessageFromRPC(Peer peer, CandidatesMessage message); +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/IceAsync.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/IceAsync.java new file mode 100644 index 0000000..1314249 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/IceAsync.java @@ -0,0 +1,13 @@ +package com.faforever.iceadapter.services; + +import com.faforever.iceadapter.ice.peer.Peer; + +import java.util.concurrent.CompletableFuture; + +public interface IceAsync { + CompletableFuture runAsync(String methodName, Peer peer, Runnable runnable); + + CompletableFuture runAsync(Peer peer, Runnable runnable); + + CompletableFuture runAsyncDelay(Peer peer, Runnable runnable, int delayMs); +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/IceTrigger.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/IceTrigger.java new file mode 100644 index 0000000..5eadf3c --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/IceTrigger.java @@ -0,0 +1,24 @@ +package com.faforever.iceadapter.services; + +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.PeerEventListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class IceTrigger implements PeerEventListener { + private final IceAsync iceAsync; + private final ConnectService connectService; + + @Override + public void onIceStateChange(Peer peer, IceState oldState, IceState newState) { + iceAsync.runAsync("onIceStateChange", peer, () -> connectService.onChangeIceState(peer, oldState, newState)); + } + + @Override + public void onConnectionLost(Peer peer) { + iceAsync.runAsync("onConnectionLost", peer, () -> connectService.onConnectionLost(peer)); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/UIAdapter.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/UIAdapter.java new file mode 100644 index 0000000..bc59a9d --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/UIAdapter.java @@ -0,0 +1,145 @@ +package com.faforever.iceadapter.services; + +import com.faforever.iceadapter.dto.IceServerView; +import com.faforever.iceadapter.dto.PeerView; +import com.faforever.iceadapter.ice.peer.IceAgentStrategy; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import javafx.collections.ObservableList; + +/** + * Interface for UI components to interact with the ICE adapter and display connection/debug information. + * Provides methods to retrieve application state, peer information, and control ICE agent behavior. + */ +public interface UIAdapter { + + /** + * Returns the current version of the ICE adapter. + * + * @return version string + */ + String getVersion(); + + /** + * Returns the username of the connected user. + * + * @return username + */ + String getUsername(); + + /** + * Returns the user ID of the connected user. + * + * @return user ID + */ + int getUserId(); + + /** + * Returns the RPC server port used by the adapter. + * + * @return RPC port number + */ + int getRpcPort(); + + /** + * Returns the GPGNet server port used by the adapter. + * + * @return GPGNet port number + */ + int getGpgNetPort(); + + /** + * Returns the lobby server port used by the adapter. + * + * @return lobby port number + */ + int getLobbyPort(); + + /** + * Returns the current status of the RPC server. + * + * @return RPC server status as string + */ + String getRpcServerStatus(); + + /** + * Returns the current status of the RPC client. + * + * @return RPC client status as string + */ + String getRpcClientStatus(); + + /** + * Returns the current status of the GPGNet server. + * + * @return GPGNet server status as string + */ + String getGpgNetServerStatus(); + + /** + * Returns the current status of the GPGNet client. + * + * @return GPGNet client status as string + */ + String getGpgNetClientStatus(); + + /** + * Returns the current game state. + * + * @return game state as string + */ + String getGameState(); + + /** + * Returns an observable list of peer connection details. + * This list can be bound to UI components for real-time updates. + * + * @return observable list of peer info objects + */ + ObservableList getPeerInfoList(); + + /** + * Returns an observable list of ICE server configurations used by the adapter. + * This list includes STUN, TURN, and other relay servers employed in the ICE negotiation process. + * The list can be bound directly to UI components (e.g., tables or dropdowns) to display or modify + * active ICE server settings in real time. + * + * @return observable list of {@link IceServerView} objects representing ICE server configurations + */ + ObservableList getIceServersList(); + + void setEnabledIceServer(IceServerView iceServer, boolean enabled); + + /** + * Requests reconnection to the specified peer. + * + * @param peer the peer to reconnect to + */ + void reconnect(PeerView peer); + + /** + * Sets the allowed combination mode for the specified peer. + * + * @param peer the target peer + * @param combination the combination mode to allow + */ + void setAllowCombination(PeerView peer, AllowCombination combination); + + /** + * Changes the ICE agent strategy for the specified peer. + * + * @param peer the target peer + * @param newStrategy the new strategy to apply + */ + void setStrategy(PeerView peer, IceAgentStrategy newStrategy); + + boolean isEnabledManualCombinationConnection(); + + boolean isEnabledManualStrategyConnection(); + + boolean isEnabledAdditionalPeerInfo(); + + /** + * Initiates a graceful shutdown of the adapter and all associated components. + */ + void shutdown(); +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceCommon.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceCommon.java new file mode 100644 index 0000000..a4d6aa0 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceCommon.java @@ -0,0 +1,284 @@ +package com.faforever.iceadapter.services.impl; + +import com.faforever.iceadapter.ice.*; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import com.faforever.iceadapter.services.IceAsync; +import com.faforever.iceadapter.util.CandidateUtil; +import com.faforever.iceadapter.util.DatagramSocketUtils; +import com.faforever.iceadapter.util.IceUtils; +import com.faforever.iceadapter.util.LockUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Agent; +import org.ice4j.ice.Component; +import org.ice4j.ice.IceMediaStream; +import org.ice4j.ice.KeepAliveStrategy; +import org.ice4j.ice.harvest.StunCandidateHarvester; +import org.ice4j.ice.harvest.TurnCandidateHarvester; +import org.ice4j.security.LongTermCredential; + +import java.util.List; +import java.util.concurrent.*; + +import static com.faforever.iceadapter.ice.IceState.*; + +@Slf4j +@RequiredArgsConstructor +public abstract class ConnectServiceCommon { + protected static final String LOCK_CONNECT = "ConnectServiceImpl"; + protected static final String FAF_MEDIA_STREAM = "faData"; + protected static final int MINIMUM_PORT = 6112; + protected static final int MAXIMUM_PORT = 7112; + protected static final int TIMEOUT_ON_CHECKING = 15000; + protected static final int LOST_CONNECT_DURATION = 5000; + protected final IceGameSession iceGameSession; + protected final IceAsync iceAsync; + + public void onChangeIceState(Peer peer, IceState oldState, IceState iceState) { + if (peer == null || iceState == null) { + return; + } + LockUtil.executeWithLock(peer.getLock(LOCK_CONNECT), () -> { + switch (iceState) { + case NEW -> onIceStateNew(peer); + case GATHERING -> onIceStateGathering(peer); + case AWAITING_CANDIDATES -> onIceAwaitingCandidates(peer); + case CHECKING -> onIceStateChecking(peer); + case CONNECTED -> onIceStateConnected(peer); + case COMPLETED -> onIceStateCompleted(peer); + case DISCONNECTED -> onIceStateDisconnected(peer, oldState); + default -> log.error("Unknown Ice State {}", iceState); + } + }); + } + + abstract void onIceStateNew(Peer peer); + + abstract void onIceStateGathering(Peer peer); + + abstract void onIceAwaitingCandidates(Peer peer); + + abstract void onIceStateChecking(Peer peer); + + abstract void onIceStateConnected(Peer peer); + + abstract void onIceStateCompleted(Peer peer); + + abstract void onIceStateDisconnected(Peer peer, IceState oldState); + + protected void createAgent(Peer peer) { + Agent oldAgent = peer.getAgent(); + + if (oldAgent != null) { + closeAgent(oldAgent); + } + + log.info("Creating agent"); + Agent agent = new Agent(); + agent.setControlling(peer.isLocalOffer()); + agent.setPerformConsentFreshness(true); + peer.setAgent(agent); + peer.setMediaStream(agent.createMediaStream(FAF_MEDIA_STREAM)); + } + + protected void gatherCandidates(Peer peer) { + log.info("Gathering ice candidates"); + + Agent agent = peer.getAgent(); + IceMediaStream mediaStream = peer.getMediaStream(); + + List servers = iceGameSession.getIceServers(); + + servers.stream() + .filter(IceServer::isStun) + .filter(IceServer::isEnabled) + .map(IceServer::getAddress) + .forEach(address -> { + log.info("Add STUN harvester for {}", address.getHostName()); + agent.addCandidateHarvester(new StunCandidateHarvester(address)); + }); + + servers.stream() + .filter(IceServer::isTurn) + .filter(IceServer::isEnabled) + .forEach(iceServer -> { + var address = iceServer.getAddress(); + var harvester = new TurnCandidateHarvester(address, new LongTermCredential(iceServer.getTurnUsername(), iceServer.getTurnCredential())); + log.info("Add TURN harvester for {}", address.getHostName()); + agent.addCandidateHarvester(harvester); + }); + + CompletableFuture gatheringFuture = iceAsync.runAsync(peer, () -> createComponent(peer, agent, mediaStream)); + + iceAsync.runAsyncDelay(peer, () -> { + if (!gatheringFuture.isDone()) { + gatheringFuture.cancel(true); + } + }, 10000); + + boolean success = true; + try { + gatheringFuture.join(); + } catch (CompletionException e) { + log.error("Error creating component", e); + success = false; + } catch (CancellationException e) { + log.error("Gathering candidates timed out", e); + success = false; + } + + if (!success) { + connectLost(peer, true); + return; + } + + AllowCombination combination = peer.getCombination(); + for (Component component : mediaStream.getComponents()) { + CandidatesMessage candidatesMessage = CandidateUtil.packCandidates( + iceGameSession.getMyId(), + peer.getRemoteId(), + agent, + component, + combination.isAllowHost(), + combination.isAllowReflexive(), + combination.isAllowRelay()); + log.debug("Sending own candidates, offered candidates: {}", candidatesMessage.toStrCandidates()); + + iceGameSession.sendToRpc(candidatesMessage); + } + } + + protected void onDisconnected(Peer peer, IceState oldState) { + log.info("ICE state disconnected"); + + peer.setLastLostConnect(System.currentTimeMillis()); + Component component = peer.getComponent(); + if (component != null) { + peer.setComponent(null); + } + IceMediaStream mediaStream = peer.getMediaStream(); + if (mediaStream != null) { + peer.setMediaStream(null); + } + Agent agent = peer.getAgent(); + if (agent != null) { + closeAgent(agent); + peer.setAgent(null); + } + + if (peer.isClosing()) { + log.warn("Peer not connected anymore, aborting onConnectionLost of ICE"); + return; + } + + if (iceGameSession.isGameEnded()) { + log.warn("GAME ENDED, ABORTING onConnectionLost of ICE for peer "); + return; + } + + if (oldState == CONNECTED) { + iceGameSession.showMessage("Reconnecting to %s (connection lost)".formatted(peer.getRemoteLogin())); + } + } + + protected void connectLost(Peer peer, boolean force) { + if (peer.getIceState() == DISCONNECTED) { + log.warn("Lost connection, albeit already in ice state disconnected"); + return; + } + long now = System.currentTimeMillis(); + long lastLostConnect = peer.getLastLostConnect(); + if (now - lastLostConnect < LOST_CONNECT_DURATION && !force) { + log.debug("Skipping the lost connection, since the last connection loss was less than {}ms ago", LOST_CONNECT_DURATION); + return; + } + peer.setLastLostConnect(now); + log.info("Lost connection"); + + peer.stopModules(); + + iceGameSession.onConnected(peer, peer.isConnected()); + + peer.setIceState(DISCONNECTED); + } + + protected boolean checking(Peer peer) { + log.debug("Checking ICE for peer"); + Agent agent = peer.getAgent(); + AgentSuccessMonitor monitor = new PeerConnectionSuccessMonitor(TIMEOUT_ON_CHECKING, peer); + CompletableFuture future = monitor.start(); + agent.startConnectivityEstablishment(); + + try { + return future.get(TIMEOUT_ON_CHECKING, TimeUnit.MILLISECONDS); + } catch (Exception e) { + log.error("Timeout while waiting for connection ICE"); + return false; + } finally { + monitor.shutdown(); + } + } + + protected void onConnected(Peer peer) { + log.info("ICE state connected"); + Component component = IceUtils.getFirstComponent(peer.getMediaStream()).orElse(null); + if (component == null) { + log.warn("Fake connection {}", peer.getMediaStream()); + connectLost(peer, true); + return; + } + + peer.setComponent(component); + + iceGameSession.onConnected(peer, true); + + log.debug("ICE terminated, connected, candidate pair: {} ", peer.getStrCandidateTypes("|")); + + peer.startModules(); + } + + protected void asyncTimeoutAwaitingCandidates(int eventId, Peer peer) { + LockUtil.executeWithLock(peer.getLock(LOCK_CONNECT), () -> { + if (peer.isClosing()) { + log.warn("Peer not connected anymore, aborting reinitiation of ICE"); + return; + } + int actualEventId = peer.getAwaitingCandidatesEventId().get(); + + if (eventId != actualEventId) { + return; + } + if (peer.getIceState() == AWAITING_CANDIDATES) { + connectLost(peer, true); + } + }); + } + + protected void createComponent(Peer peer, Agent agent, IceMediaStream mediaStream) { + try { + KeepAliveStrategy strategy = peer.getKeepAliveStrategy() != null ? peer.getKeepAliveStrategy() : KeepAliveStrategy.SELECTED_ONLY; + Component component = agent.createComponent(mediaStream, ThreadLocalRandom.current().nextInt(MINIMUM_PORT, MAXIMUM_PORT), MINIMUM_PORT, MAXIMUM_PORT, strategy); + DatagramSocketUtils.resizeBuffer(component.getSocket()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected void closeAgent(Agent agent) { + log.info("Close agent"); + try { + for (IceMediaStream stream : agent.getStreams()) { + for (Component streamComponent : stream.getComponents()) { + stream.removeComponent(streamComponent); + } + agent.removeStream(stream); + } + agent.free(); + } catch (Exception e) { + log.warn("Error freeing existing agent", e); + } + } + + abstract void onConnectionLost(Peer peer); +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceControlledImpl.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceControlledImpl.java new file mode 100644 index 0000000..452cf9f --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceControlledImpl.java @@ -0,0 +1,115 @@ +package com.faforever.iceadapter.services.impl; + +import com.faforever.iceadapter.ice.CandidatesMessage; +import com.faforever.iceadapter.ice.IceGameSession; +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.services.ConnectService; +import com.faforever.iceadapter.services.IceAsync; +import com.faforever.iceadapter.util.CandidateUtil; +import com.faforever.iceadapter.util.LockUtil; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Agent; +import org.ice4j.ice.Component; +import org.ice4j.ice.IceMediaStream; + +import static com.faforever.iceadapter.ice.IceState.*; + +@Slf4j +public class ConnectServiceControlledImpl extends ConnectServiceCommon implements ConnectService { + + public ConnectServiceControlledImpl(IceGameSession iceGameSession, IceAsync iceAsync) { + super(iceGameSession, iceAsync); + } + + void onIceStateNew(Peer peer) { + createAgent(peer); + peer.setIceState(GATHERING); + } + + void onIceStateGathering(Peer peer) { + gatherCandidates(peer); + peer.setIceState(AWAITING_CANDIDATES); + } + + void onIceAwaitingCandidates(Peer peer) { + // Make sure to abort the connection process and reinitiate when we haven't received an answer to our offer in 6 + // seconds, candidate packet was probably lost + final int currentAwaitingCandidatesEventId = peer.getAwaitingCandidatesEventId().incrementAndGet(); + iceAsync.runAsyncDelay(peer, () -> asyncTimeoutAwaitingCandidates(currentAwaitingCandidatesEventId, peer), 5000); + } + + void onIceStateDisconnected(Peer peer, IceState oldState) { + onDisconnected(peer, oldState); + tryReInitState(peer, oldState); + } + + void onIceStateCompleted(Peer peer) { + log.info("ICE state completed"); + } + + void onIceStateChecking(Peer peer) { + boolean connected = checking(peer); + if (connected) { + peer.setIceState(CONNECTED); + } else { + connectLost(peer, true); + } + } + + void onIceStateConnected(Peer peer) { + onConnected(peer); + } + + @Override + public void onConnectionLost(Peer peer) { + if (peer == null) { + return; + } + LockUtil.executeWithLock(peer.getLock(LOCK_CONNECT), () -> connectLost(peer, false)); + } + + private void tryReInitState(Peer peer, IceState oldState) { + if (oldState == CONNECTED) { + iceAsync.runAsyncDelay(peer, () -> peer.setIceState(NEW), 1000); + } else { + iceAsync.runAsyncDelay(peer, () -> peer.setIceState(NEW), 5000); + } + } + + @Override + public void onMessageFromRPC(Peer peer, CandidatesMessage message) { + LockUtil.executeWithLock(peer.getLock(LOCK_CONNECT), () -> { + onGetCandidates(peer, message); + }); + } + + private void onGetCandidates(Peer peer, CandidatesMessage message) { + if (peer.isClosing()) { + log.warn("Peer not connected anymore, discarding ice message"); + return; + } + + log.debug("Got CandidatesMessage for peer, offered candidates: {}", message.toStrCandidates()); + if (peer.getIceState() != AWAITING_CANDIDATES) { + log.warn("Received candidates unexpectedly, current state: {}", peer.getIceState()); + return; + } + + Agent agent = peer.getAgent(); + IceMediaStream mediaStream = peer.getMediaStream(); + + for (Component component : mediaStream.getComponents()) { + CandidateUtil.unpackCandidates( + message, + agent, + component, + mediaStream, + true, + true, + true); + } + + peer.setIceState(CHECKING); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceHandler.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceHandler.java new file mode 100644 index 0000000..30bd304 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceHandler.java @@ -0,0 +1,54 @@ +package com.faforever.iceadapter.services.impl; + +import com.faforever.iceadapter.ice.CandidatesMessage; +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.services.ConnectService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class ConnectServiceHandler implements ConnectService { + private final ConnectService controlledConnectService; + private final ConnectService notControlledConnectService; + + @Override + public void onChangeIceState(Peer peer, IceState oldState, IceState iceState) { + if (peer == null) { + return; + } + + if (peer.isLocalOffer()) { + controlledConnectService.onChangeIceState(peer, oldState, iceState); + } else { + notControlledConnectService.onChangeIceState(peer, oldState, iceState); + } + } + + @Override + public void onConnectionLost(Peer peer) { + if (peer == null) { + return; + } + + if (peer.isLocalOffer()) { + controlledConnectService.onConnectionLost(peer); + } else { + notControlledConnectService.onConnectionLost(peer); + } + } + + @Override + public void onMessageFromRPC(Peer peer, CandidatesMessage message) { + if (message == null || peer == null) { + return; + } + + if (peer.isLocalOffer()) { + controlledConnectService.onMessageFromRPC(peer, message); + } else { + notControlledConnectService.onMessageFromRPC(peer, message); + } + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceNotControlledImpl.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceNotControlledImpl.java new file mode 100644 index 0000000..7127df4 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/ConnectServiceNotControlledImpl.java @@ -0,0 +1,109 @@ +package com.faforever.iceadapter.services.impl; + +import com.faforever.iceadapter.ice.CandidatesMessage; +import com.faforever.iceadapter.ice.IceGameSession; +import com.faforever.iceadapter.ice.IceState; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.services.ConnectService; +import com.faforever.iceadapter.services.IceAsync; +import com.faforever.iceadapter.util.CandidateUtil; +import com.faforever.iceadapter.util.LockUtil; +import lombok.extern.slf4j.Slf4j; +import org.ice4j.ice.Agent; +import org.ice4j.ice.Component; +import org.ice4j.ice.IceMediaStream; + +import static com.faforever.iceadapter.ice.IceState.*; + +@Slf4j +public class ConnectServiceNotControlledImpl extends ConnectServiceCommon implements ConnectService { + + public ConnectServiceNotControlledImpl(IceGameSession iceGameSession, IceAsync iceAsync) { + super(iceGameSession, iceAsync); + } + + void onIceStateNew(Peer peer) { + } + + void onIceStateGathering(Peer peer) { + // Nothing to do. Job for controlled peer + } + + void onIceAwaitingCandidates(Peer peer) { + // Nothing to do. Job for controlled peer + } + + void onIceStateCompleted(Peer peer) { + log.info("ICE state completed"); + } + + @Override + void onIceStateDisconnected(Peer peer, IceState oldState) { + onDisconnected(peer, oldState); + } + + void onIceStateChecking(Peer peer) { + boolean connected = checking(peer); + if (connected) { + peer.setIceState(CONNECTED); + } else { + connectLost(peer, true); + } + } + + void onIceStateConnected(Peer peer) { + onConnected(peer); + } + + @Override + public void onConnectionLost(Peer peer) { + if (peer == null) { + return; + } + LockUtil.executeWithLock(peer.getLock(LOCK_CONNECT), () -> connectLost(peer, false)); + } + + @Override + public void onMessageFromRPC(Peer peer, CandidatesMessage message) { + LockUtil.executeWithLock(peer.getLock(LOCK_CONNECT), () -> { + logicOnIceMessageReceived(peer, message); + }); + } + + private void logicOnIceMessageReceived(Peer peer, CandidatesMessage message) { + if (peer.isClosing()) { + log.warn("Peer not connected anymore, discarding ice message"); + return; + } + + log.debug("Got IceMsg for peer, offered candidates: {}", message.toStrCandidates()); + + IceState iceState = peer.getIceState(); + + if (iceState != NEW && iceState != DISCONNECTED) { + peer.setIceStateWithoutTrigger(DISCONNECTED); + log.info("Restarting the connection..."); + onDisconnected(peer, iceState); + } + + createAgent(peer); + gatherCandidates(peer); + + Agent agent = peer.getAgent(); + IceMediaStream mediaStream = peer.getMediaStream(); + + for (Component component : mediaStream.getComponents()) { + CandidateUtil.unpackCandidates( + message, + agent, + component, + mediaStream, + true, + true, + true); + } + + onIceStateChecking(peer); + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/IceAsyncImpl.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/IceAsyncImpl.java new file mode 100644 index 0000000..8b51e99 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/IceAsyncImpl.java @@ -0,0 +1,59 @@ +package com.faforever.iceadapter.services.impl; + +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.services.IceAsync; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; +import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +public class IceAsyncImpl implements IceAsync { + private final ExecutorService executorService; + private final ScheduledExecutorService scheduledExecutorService; + + @Override + public CompletableFuture runAsync(String methodName, Peer peer, Runnable runnable) { + return CompletableFuture.runAsync(() -> doBeforeRun(methodName, peer, runnable), executorService); + } + + @Override + public CompletableFuture runAsync(Peer peer, Runnable runnable) { + return CompletableFuture.runAsync(() -> doBeforeRun(peer, runnable), executorService); + } + + @Override + public CompletableFuture runAsyncDelay(Peer peer, Runnable runnable, int delayMs) { + return CompletableFuture.runAsync(() -> doBeforeRun(peer, runnable), + CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS, executorService)); + } + + private void doBeforeRun(String methodName, Peer peer, Runnable runnable) { + Thread.currentThread().setName(createNameForThread(methodName, getPeerName(peer))); + runnable.run(); + } + + private void doBeforeRun(Peer peer, Runnable runnable) { + doBeforeRun(null, peer, runnable); + } + + private String createNameForThread(Object... args) { + StringJoiner joiner = new StringJoiner("-"); + for (Object arg : args) { + if (arg != null) { + joiner.add(String.valueOf(arg)); + } + } + return joiner.toString(); + } + + private String getPeerName(Peer peer) { + return Optional.ofNullable(peer) + .map(Peer::getPeerIdentifier) + .orElse(null); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/UIAdapterImpl.java b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/UIAdapterImpl.java new file mode 100644 index 0000000..3b58809 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/services/impl/UIAdapterImpl.java @@ -0,0 +1,223 @@ +package com.faforever.iceadapter.services.impl; + +import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.IceOptions; +import com.faforever.iceadapter.dto.IceServerView; +import com.faforever.iceadapter.dto.PeerView; +import com.faforever.iceadapter.gpgnet.GPGNetServer; +import com.faforever.iceadapter.ice.IceGameSession; +import com.faforever.iceadapter.ice.IceServer; +import com.faforever.iceadapter.ice.peer.IceAgentStrategy; +import com.faforever.iceadapter.ice.peer.Peer; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import com.faforever.iceadapter.rpc.RPCService; +import com.faforever.iceadapter.services.UIAdapter; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static com.faforever.iceadapter.debug.Debug.debug; + +@Slf4j +@RequiredArgsConstructor +public class UIAdapterImpl implements UIAdapter { + + private final IceAdapter iceAdapter; + + private final Map uiPeers = new ConcurrentHashMap<>(); + + private Optional getGameSession() { + return Optional.ofNullable(iceAdapter.getGameSession()); + } + + @Override + public String getVersion() { + return IceAdapter.getVersion(); + } + + @Override + public String getUsername() { + return IceAdapter.getLogin(); + } + + @Override + public int getUserId() { + return IceAdapter.getId(); + } + + @Override + public int getRpcPort() { + return Optional.ofNullable(iceAdapter.getRpcService()) + .map(RPCService::getPort) + .orElse(-1); + } + + @Override + public int getGpgNetPort() { + return Optional.ofNullable(iceAdapter.getGpgNetServer()) + .map(GPGNetServer::getGpgNetPort) + .orElse(-1); + } + + @Override + public int getLobbyPort() { + return Optional.ofNullable(iceAdapter.getGpgNetServer()) + .map(GPGNetServer::getLobbyPort) + .orElse(-1); + } + + @Override + public String getRpcServerStatus() { + RPCService rpc = iceAdapter.getRpcService(); + return rpc != null ? rpc.getHost() : "Not found"; + } + + @Override + public String getRpcClientStatus() { + return "N/A"; + } + + @Override + public String getGpgNetServerStatus() { + GPGNetServer server = iceAdapter.getGpgNetServer(); + return server != null && server.isServerRunning() ? "Running" : "Stopped"; + } + + @Override + public String getGpgNetClientStatus() { + GPGNetServer server = iceAdapter.getGpgNetServer(); + return server != null && server.isConnected() ? "Connected" : "Disconnected"; + } + + @Override + public String getGameState() { + GPGNetServer server = iceAdapter.getGpgNetServer(); + return server != null && server.getGameState().isPresent() + ? server.getGameState().get().name() + : "UNKNOWN"; + } + + @Override + public ObservableList getPeerInfoList() { + Map peers = getGameSession() + .map(IceGameSession::getPeers) + .orElse(Collections.emptyMap()); + + Set ids = peers.keySet(); + + if (!Objects.equals(uiPeers.keySet(), ids)) { + uiPeers.keySet().stream() + .filter(id -> !ids.contains(id)) + .forEach(uiPeers::remove); + } + + peers.values().forEach(peer -> { + debug().peerStateChanged(peer); + debug().peerConnectivityUpdate(peer); + }); + + return FXCollections.observableArrayList( + peers.values() + .stream() + .sorted((p1, p2) -> Comparator.comparingInt(Peer::getRemoteId).compare(p1, p2)) + .map(this::toPeerInfo) + .collect(Collectors.toList()) + ); + } + + @Override + public ObservableList getIceServersList() { + List servers = getGameSession().map(IceGameSession::getIceServers) + .orElse(Collections.emptyList()); + + return FXCollections.observableArrayList(servers.stream() + .map(IceServerView::new) + .toList()); + } + + @Override + public void setEnabledIceServer(IceServerView view, boolean enabled) { + if (view == null) { + return; + } + IceServer iceServer = view.getServer(); + iceServer.setEnabled(enabled); + iceServer.setAuto(false); + } + + private PeerView toPeerInfo(Peer peer) { + PeerView info = uiPeers.computeIfAbsent(peer.getRemoteId(), id -> { + PeerView uiInfo = new PeerView(peer.getRemoteId(), peer.getRemoteLogin()); + peer.addEventListener(uiInfo); + return uiInfo; + }); + + info.update(peer); + + return info; + } + + @Override + public void reconnect(PeerView peer) { + if (peer == null) { + return; + } + int id = peer.getId().get(); + getGameSession() + .flatMap(session -> session.getPeer(id)) + .ifPresent(Peer::reconnect); + } + + @Override + public void setAllowCombination(PeerView peer, AllowCombination combination) { + if (peer == null || combination == null) { + return; + } + int id = peer.getId().get(); + getGameSession() + .flatMap(session -> session.getPeer(id)) + .ifPresent(p -> p.setCombination(combination)); + } + + @Override + public void setStrategy(PeerView peer, IceAgentStrategy newStrategy) { + if (peer == null || newStrategy == null) { + return; + } + int id = peer.getId().get(); + getGameSession() + .flatMap(session -> session.getPeer(id)) + .ifPresent(p -> p.setAgentStrategy(newStrategy)); + } + + @Override + public boolean isEnabledManualCombinationConnection() { + return Optional.ofNullable(iceAdapter.getIceOptions()) + .map(IceOptions::isManualCombinationConnection) + .orElse(false); + } + + @Override + public boolean isEnabledManualStrategyConnection() { + return Optional.ofNullable(iceAdapter.getIceOptions()) + .map(IceOptions::isManualStrategyConnection) + .orElse(false); + } + + @Override + public boolean isEnabledAdditionalPeerInfo() { + return Optional.ofNullable(iceAdapter.getIceOptions()) + .map(IceOptions::isAdditionalInfoPeer) + .orElse(false); + } + + @Override + public void shutdown() { + IceAdapter.close(0); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/ConnectToPeer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/ConnectToPeer.java index c8d17d1..cdb4224 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/ConnectToPeer.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/ConnectToPeer.java @@ -3,4 +3,10 @@ import java.util.UUID; public record ConnectToPeer(UUID messageId, int peerPlayerId, String peerName, boolean localOffer) - implements OutgoingMessageV1 {} + implements OutgoingMessageV1 { + + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/DisconnectFromPeer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/DisconnectFromPeer.java index 8f0aa8d..6c62f2e 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/DisconnectFromPeer.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/DisconnectFromPeer.java @@ -2,4 +2,10 @@ import java.util.UUID; -public record DisconnectFromPeer(UUID messageId, int peerPlayerId) implements OutgoingMessageV1 {} +public record DisconnectFromPeer(UUID messageId, int peerPlayerId) implements OutgoingMessageV1 { + + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/OutgoingMessageV1.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/OutgoingMessageV1.java index da882e6..316072c 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/OutgoingMessageV1.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/OutgoingMessageV1.java @@ -1,9 +1,12 @@ package com.faforever.iceadapter.telemetry; import com.fasterxml.jackson.annotation.JsonTypeInfo; + import java.util.UUID; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "messageType") public interface OutgoingMessageV1 { UUID messageId(); + + String getType(); } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/RegisterAsPeer.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/RegisterAsPeer.java index e1eddc9..1a8ba02 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/RegisterAsPeer.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/RegisterAsPeer.java @@ -2,4 +2,9 @@ import java.util.UUID; -public record RegisterAsPeer(UUID messageId, String adapterVersion, String userName) implements OutgoingMessageV1 {} +public record RegisterAsPeer(UUID messageId, String adapterVersion, String userName) implements OutgoingMessageV1 { + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateCoturnList.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateCoturnList.java index f5c064c..926153d 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateCoturnList.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateCoturnList.java @@ -4,4 +4,10 @@ import java.util.UUID; public record UpdateCoturnList(UUID messageId, String connectedHost, Collection knownServers) - implements OutgoingMessageV1 {} + implements OutgoingMessageV1 { + + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGameState.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGameState.java index 2542c74..9f930a2 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGameState.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGameState.java @@ -1,6 +1,12 @@ package com.faforever.iceadapter.telemetry; import com.faforever.iceadapter.gpgnet.GameState; + import java.util.UUID; -public record UpdateGameState(UUID messageId, GameState newState) implements OutgoingMessageV1 {} +public record UpdateGameState(UUID messageId, GameState newState) implements OutgoingMessageV1 { + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGpgnetState.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGpgnetState.java index 7b38d56..a664c01 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGpgnetState.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdateGpgnetState.java @@ -2,4 +2,10 @@ import java.util.UUID; -public record UpdateGpgnetState(UUID messageId, String newState) implements OutgoingMessageV1 {} +public record UpdateGpgnetState(UUID messageId, String newState) implements OutgoingMessageV1 { + + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerConnectivity.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerConnectivity.java index ad1d46e..e09718b 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerConnectivity.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerConnectivity.java @@ -4,4 +4,10 @@ import java.util.UUID; public record UpdatePeerConnectivity(UUID messageId, int peerPlayerId, Float averageRTT, Instant lastReceived) - implements OutgoingMessageV1 {} + implements OutgoingMessageV1 { + + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerState.java b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerState.java index 69483f4..e80cf45 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerState.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/telemetry/UpdatePeerState.java @@ -1,13 +1,19 @@ package com.faforever.iceadapter.telemetry; import com.faforever.iceadapter.ice.IceState; -import java.util.UUID; import org.ice4j.ice.CandidateType; +import java.util.UUID; + public record UpdatePeerState( UUID messageId, int peerPlayerId, IceState iceState, CandidateType localCandidate, CandidateType remoteCandidate) - implements OutgoingMessageV1 {} + implements OutgoingMessageV1 { + @Override + public String getType() { + return getClass().getSimpleName(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceServerWindow.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceServerWindow.java new file mode 100644 index 0000000..fd32fd4 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceServerWindow.java @@ -0,0 +1,82 @@ +package com.faforever.iceadapter.ui; + +import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.LogoUtils; +import com.faforever.iceadapter.services.impl.UIAdapterImpl; +import com.faforever.iceadapter.ui.controller.IceServerController; +import javafx.application.Platform; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Stage; +import lombok.extern.slf4j.Slf4j; + +import static javafx.application.Application.STYLESHEET_MODENA; +import static javafx.application.Application.setUserAgentStylesheet; + +@Slf4j +public class IceServerWindow { + + public static IceServerWindow INSTANCE; + + private IceServerController controller; + + private Stage stage; + private Parent root; + + public void start(Stage primaryStage) { + INSTANCE = this; + this.stage = primaryStage; + LogoUtils.getLogoFx().ifPresent(logo -> { + stage.getIcons().add(logo); + }); + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/IceServerTable.fxml")); + root = loader.load(); + controller = loader.getController(); + controller.setAdapter(new UIAdapterImpl(IceAdapter.INSTANCE)); + controller.initialize(); + } catch (Exception e) { + log.error("Failed to load FXML", e); + return; + } + + setUserAgentStylesheet(STYLESHEET_MODENA); + + Scene scene = new Scene(root); + primaryStage.setTitle("ICE Server Manager"); + primaryStage.setScene(scene); + primaryStage.setOnCloseRequest(event -> minimize()); + primaryStage.show(); + } + + public void showWindow() { + runOnUIThread(() -> { + stage.show(); + stage.toFront(); + stage.requestFocus(); + }); + } + + public void minimize() { + Platform.setImplicitExit(false); + runOnUIThread(stage::hide); + } + + private static void runOnUIThread(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } + + public static void launch() { + log.info("Launching IceServerTableApp window."); + if (INSTANCE == null) { + runOnUIThread(() -> new IceServerWindow().start(new Stage())); + } else { + INSTANCE.showWindow(); + } + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceWindow.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceWindow.java new file mode 100644 index 0000000..6645158 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/IceWindow.java @@ -0,0 +1,87 @@ +package com.faforever.iceadapter.ui; + +import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.LogoUtils; +import com.faforever.iceadapter.debug.Debug; +import com.faforever.iceadapter.services.impl.UIAdapterImpl; +import com.faforever.iceadapter.ui.controller.WindowController; +import javafx.application.Application; +import javafx.application.Platform; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Stage; +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Slf4j +@EqualsAndHashCode(callSuper = false) +public class IceWindow extends Application { + public static IceWindow INSTANCE; + + private Parent root; + private Scene scene; + private WindowController controller; + private Stage stage; + + @Override + public void start(Stage stage) { + INSTANCE = this; + this.stage = stage; + LogoUtils.getLogoFx().ifPresent(logo -> { + stage.getIcons().add(logo); + }); + + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/iceWindow.fxml")); + root = loader.load(); + + controller = loader.getController(); + controller.setAdapter(new UIAdapterImpl(IceAdapter.INSTANCE)); + controller.initialize(); + } catch (IOException e) { + log.error("Could not load debugger window fxml", e); + } + + setUserAgentStylesheet(STYLESHEET_MODENA); + + scene = new Scene(root); + + stage.setScene(scene); + stage.setTitle("FAF ICE adapter - Debugger - Build: %s".formatted(IceAdapter.getVersion())); + + if (Debug.ENABLE_DEBUG_WINDOW) { + CompletableFuture.runAsync( + () -> runOnUIThread(stage::show), + CompletableFuture.delayedExecutor(Debug.DELAY_UI_MS, TimeUnit.MILLISECONDS)); + } + + log.info("Created debug window."); + } + + public void showWindow() { + runOnUIThread(() -> { + stage.show(); + stage.toFront(); + stage.requestFocus(); + }); + } + + + private static void runOnUIThread(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } + + public static void launch() { + log.info("Launching ice window."); + launch(IceWindow.class, null); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/InfoWindow.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/InfoWindow.java similarity index 59% rename from ice-adapter/src/main/java/com/faforever/iceadapter/debug/InfoWindow.java rename to ice-adapter/src/main/java/com/faforever/iceadapter/ui/InfoWindow.java index 7ed324a..68cce1b 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/InfoWindow.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/InfoWindow.java @@ -1,18 +1,19 @@ -package com.faforever.iceadapter.debug; +package com.faforever.iceadapter.ui; -import static javafx.application.Application.STYLESHEET_MODENA; -import static javafx.application.Application.setUserAgentStylesheet; - -import java.io.IOException; +import com.faforever.iceadapter.LogoUtils; +import com.faforever.iceadapter.ui.controller.InfoWindowController; import javafx.application.Platform; -import javafx.event.Event; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; -import javafx.scene.image.Image; import javafx.stage.Stage; import lombok.extern.slf4j.Slf4j; +import java.io.IOException; + +import static javafx.application.Application.STYLESHEET_MODENA; +import static javafx.application.Application.setUserAgentStylesheet; + @Slf4j public class InfoWindow { @@ -26,31 +27,28 @@ public class InfoWindow { private static final int WIDTH = 533; private static final int HEIGHT = 330; - public InfoWindow() { + public void start(Stage stage) { INSTANCE = this; - } - - public void init() { - stage = new Stage(); - stage.getIcons().add(new Image("https://faforever.com/images/faf-logo.png")); - + this.stage = stage; + LogoUtils.getLogoFx().ifPresent(logo -> { + stage.getIcons().add(logo); + }); try { FXMLLoader loader = new FXMLLoader(getClass().getResource("/infoWindow.fxml")); root = loader.load(); - controller = loader.getController(); - } catch (IOException e) { log.error("Could not load debugger window fxml", e); } + setUserAgentStylesheet(STYLESHEET_MODENA); scene = new Scene(root, WIDTH, HEIGHT); stage.setScene(scene); stage.setTitle("FAF ICE adapter"); - stage.setOnCloseRequest(Event::consume); + stage.setOnCloseRequest(event -> minimize()); stage.show(); log.info("Created info window."); @@ -58,13 +56,29 @@ public void init() { public void minimize() { Platform.setImplicitExit(false); - Platform.runLater(this.stage::hide); + runOnUIThread(this.stage::hide); } - public void show() { - Platform.runLater(() -> { + public void showWindow() { + runOnUIThread(() -> { this.stage.show(); - Platform.setImplicitExit(true); }); } + + private static void runOnUIThread(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } + + public static void launch() { + log.info("Launching info window."); + if (INSTANCE == null) { + runOnUIThread(() -> new InfoWindow().start(new Stage())); + } else { + INSTANCE.showWindow(); + } + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/IceServerController.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/IceServerController.java new file mode 100644 index 0000000..bbba303 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/IceServerController.java @@ -0,0 +1,112 @@ +package com.faforever.iceadapter.ui.controller; + +import com.faforever.iceadapter.dto.IceServerView; +import com.faforever.iceadapter.services.UIAdapter; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.scene.control.CheckBox; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.layout.VBox; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@NoArgsConstructor +public class IceServerController { + + @FXML + private VBox root; + + @FXML + private TableView tableView; + + @FXML + private TableColumn typeColumn; + @FXML + private TableColumn transportColumn; + @FXML + private TableColumn addressColumn; + @FXML + private TableColumn rttColumn; + @FXML + private TableColumn enabledColumn; + + private UIAdapter adapter; + private ScheduledExecutorService updateScheduler; + + public void setAdapter(UIAdapter adapter) { + this.adapter = adapter; + updateAllInfo(); + } + + public void initialize() { + initColumns(); + startPeriodicUpdates(); + } + + private void initColumns() { + typeColumn.setCellValueFactory(data -> data.getValue().getType()); + transportColumn.setCellValueFactory(data -> data.getValue().getTransport()); + addressColumn.setCellValueFactory(data -> data.getValue().getAddress()); + rttColumn.setCellValueFactory(data -> data.getValue().getRtt()); + enabledColumn.setCellValueFactory(data -> data.getValue().getEnabled()); + enabledColumn.setEditable(true); + enabledColumn.setCellFactory(col -> new TableCell() { + private final CheckBox checkBox = new CheckBox(); + + { + checkBox.setOnAction(event -> { + IceServerView item = getTableView().getItems().get(getIndex()); + adapter.setEnabledIceServer(item, checkBox.isSelected()); + }); + } + + @Override + protected void updateItem(Boolean item, boolean empty) { + super.updateItem(item, empty); + + setGraphic(null); + setText(null); + + if (empty || item == null) { + setGraphic(null); + } else { + IceServerView server = getTableView().getItems().get(getIndex()); + if (server.getServer().isTurn()) { + checkBox.setSelected(item); + setGraphic(checkBox); + } else { + setText("Auto"); + } + } + } + }); + } + + private void updateAllInfo() { + if (adapter == null) { + return; + } + + Platform.runLater(() -> { + if (!Objects.equals(adapter.getIceServersList().size(), tableView.getItems().size())) { + tableView.getItems().setAll(adapter.getIceServersList()); + } + }); + } + + private void startPeriodicUpdates() { + updateScheduler = Executors.newSingleThreadScheduledExecutor(); + updateScheduler.scheduleAtFixedRate(() -> { + Platform.runLater(this::updateAllInfo); + }, 2, 30, TimeUnit.SECONDS); + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/InfoWindowController.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/InfoWindowController.java similarity index 84% rename from ice-adapter/src/main/java/com/faforever/iceadapter/debug/InfoWindowController.java rename to ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/InfoWindowController.java index c6f2122..6889365 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/debug/InfoWindowController.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/InfoWindowController.java @@ -1,16 +1,20 @@ -package com.faforever.iceadapter.debug; +package com.faforever.iceadapter.ui.controller; import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.debug.Debug; +import com.faforever.iceadapter.ui.IceWindow; +import com.faforever.iceadapter.ui.InfoWindow; import com.faforever.iceadapter.util.TrayIcon; -import java.awt.*; -import java.io.IOException; -import java.net.URI; -import java.util.concurrent.CompletableFuture; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import lombok.SneakyThrows; +import java.awt.*; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.CompletableFuture; + public class InfoWindowController { public Button killAdapterButton; public Button showDebugWindowButton; @@ -31,7 +35,13 @@ public void onKillAdapterClicked(ActionEvent actionEvent) { } public void onShowDebugWindowClicked(ActionEvent actionEvent) { - DebugWindow.INSTANCE.thenAcceptAsync(DebugWindow::showWindow, IceAdapter.getExecutor()); + if (IceWindow.INSTANCE == null) { + Debug.ENABLE_DEBUG_WINDOW = true; + IceWindow.launch(); + } else { + IceWindow.INSTANCE.showWindow(); + } + } @SneakyThrows @@ -49,8 +59,7 @@ public void onTelemetryWebUiClicked(ActionEvent actionEvent) { } catch (IOException e) { throw new RuntimeException(e); } - }, - IceAdapter.getExecutor()); + }); } public void onMinimizeToTrayClicked(ActionEvent actionEvent) { diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/WindowController.java b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/WindowController.java new file mode 100644 index 0000000..d980a78 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/ui/controller/WindowController.java @@ -0,0 +1,313 @@ +package com.faforever.iceadapter.ui.controller; + +import com.faforever.iceadapter.dto.PeerView; +import com.faforever.iceadapter.ice.peer.IceAgentStrategy; +import com.faforever.iceadapter.ice.peer.modules.AllowCombination; +import com.faforever.iceadapter.services.UIAdapter; +import com.faforever.iceadapter.ui.IceServerWindow; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.scene.control.*; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@NoArgsConstructor +public class WindowController { + + @FXML + private AnchorPane root; + + @FXML + private Button killAdapterButton; + + @FXML + private Label versionLabel, userLabel, rpcPortLabel, gpgnetPortLabel, lobbyPortLabel; + + @FXML + private Label rpcServerStatus, rpcClientStatus, gpgnetServerStatus, gpgnetClientStatus, gameState; + + @FXML + private TableView peerTable; + @FXML + private TableColumn idColumn; + @FXML + private TableColumn loginColumn; + @FXML + private TableColumn pairConColumn; + @FXML + private TableColumn reconnectColumn; + @FXML + private TableColumn stateColumn; + @FXML + private TableColumn agentStateColumn; + @FXML + private TableColumn offerColumn; + @FXML + private TableColumn rttColumn; + @FXML + private TableColumn lastColumn; + @FXML + private TableColumn echosRcvColumn; + @FXML + private TableColumn hostColumn; + + @FXML + private TableColumn reflexiveColumn; + @FXML + private TableColumn relayColumn; + + @FXML + private VBox peerActionPane; + @FXML + private Label peerActionTitle; + @FXML + private Button reconnectPeerButton; + @FXML + private ComboBox allowCombinationComboBox; + @FXML + private ComboBox connectionStrategyComboBox; + + @FXML + private TextArea pairCandidateInfoArea; + + private UIAdapter adapter; + private ScheduledExecutorService updateScheduler; + + private PeerView selectedPeer; + + public void openSettingsStunAndTurn() { + CompletableFuture.runAsync( + () -> runOnUIThread(IceServerWindow::launch)); + } + + public void initialize() { + setupButtonActions(); + setupPeerTable(); + startPeriodicUpdates(); + updateAllInfo(); + } + + public void setAdapter(UIAdapter adapter) { + this.adapter = adapter; + updateAllInfo(); + } + + private void setupButtonActions() { + killAdapterButton.setOnAction(event -> { + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Confirm Close"); + alert.setHeaderText("Close ICE Adapter?"); + alert.setContentText("This will disconnect you from the game."); + alert.showAndWait().ifPresent(response -> { + if (response == ButtonType.OK && adapter != null) { + adapter.shutdown(); + } + }); + }); + } + + private void setupPeerTable() { + idColumn.setCellValueFactory(cellData -> cellData.getValue().getId().asObject()); + loginColumn.setCellValueFactory(cellData -> cellData.getValue().getLogin()); + + pairConColumn.setCellValueFactory(cellData -> cellData.getValue().getPairConnection()); + stateColumn.setCellValueFactory(cellData -> cellData.getValue().getState()); + agentStateColumn.setCellValueFactory(cellData -> cellData.getValue().getAgent()); + offerColumn.setCellValueFactory(cellData -> cellData.getValue().getOffer()); + rttColumn.setCellValueFactory(cellData -> cellData.getValue().getRtt()); + lastColumn.setCellValueFactory(cellData -> cellData.getValue().getLastRecv()); + echosRcvColumn.setCellValueFactory(cellData -> cellData.getValue().getEchosReceived()); + + hostColumn.setCellValueFactory(param -> param.getValue().getAdditionalInfo().getAllowHost()); + hostColumn.setCellFactory(CheckBoxTableCell.forTableColumn(hostColumn)); + reflexiveColumn.setCellValueFactory(peer -> peer.getValue().getAdditionalInfo().getAllowReflexive()); + reflexiveColumn.setCellFactory(CheckBoxTableCell.forTableColumn(reflexiveColumn)); + relayColumn.setCellValueFactory(peer -> peer.getValue().getAdditionalInfo().getAllowRelay()); + relayColumn.setCellFactory(CheckBoxTableCell.forTableColumn(relayColumn)); + + reconnectColumn.setCellFactory(param -> new TableCell<>() { + private final Button button = new Button("Reconnect"); + + { + button.setOnAction(event -> { + PeerView peer = getTableView().getItems().get(getIndex()); + if (peer != null && adapter != null) { + adapter.reconnect(peer); + } + }); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty) { + setGraphic(null); + } else { + PeerView peer = getTableView().getItems().get(getIndex()); + if (peer != null) { + button.setDisable(!peer.getConnected().get()); + setGraphic(button); + } else { + setGraphic(null); + } + } + } + }); + } + + @FXML + private void closePeerManagerPanel() { + peerActionPane.setVisible(false); + peerActionPane.setManaged(false); + selectedPeer = null; + peerTable.getSelectionModel().clearSelection(); + } + + private void setSelectedPeer(PeerView peer) { + + if (!isAdditionalPanelEnabled()) { + return; + } + + if (Objects.equals(selectedPeer, peer)) { + return; + } + + if (peer == null) { + // Hide panel + peerActionPane.setVisible(false); + peerActionPane.setManaged(false); + selectedPeer = null; + return; + } + selectedPeer = peer; + // Show panel + peerActionPane.setVisible(true); + peerActionPane.setManaged(true); + + peerActionTitle.setText(peer.getLogin().get()); + + allowCombinationComboBox.getItems().setAll(AllowCombination.values()); + allowCombinationComboBox.setValue(peer.getAdditionalInfo().getCombination()); + allowCombinationComboBox.setVisible(adapter.isEnabledManualCombinationConnection()); + + connectionStrategyComboBox.getItems().setAll(IceAgentStrategy.values()); + connectionStrategyComboBox.setValue(peer.getAdditionalInfo().getAgentStrategy()); + connectionStrategyComboBox.setVisible(adapter.isEnabledManualStrategyConnection()); + + allowCombinationComboBox.valueProperty().addListener((observable, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + adapter.setAllowCombination(peer, newValue); + } + }); + + connectionStrategyComboBox.valueProperty().addListener((observable, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + adapter.setStrategy(peer, newValue); + } + }); + + reconnectPeerButton.setOnAction(e -> { + adapter.reconnect(peer); + }); + + updatePairCandidateInfo(peer.getAdditionalInfo().getGetFullCandidateInfo().get()); + } + + private boolean isAdditionalPanelEnabled() { + return Optional.ofNullable(adapter) + .map(adapter -> adapter.isEnabledAdditionalPeerInfo() + || adapter.isEnabledManualCombinationConnection() + || adapter.isEnabledManualStrategyConnection()) + .orElse(false); + } + + private void updatePairCandidateInfo(String info) { + if (StringUtils.isEmpty(info)) { + pairCandidateInfoArea.setText("No candidate information available."); + } else { + pairCandidateInfoArea.setText(info); + } + pairCandidateInfoArea.setScrollTop(0); + pairCandidateInfoArea.setVisible(adapter.isEnabledAdditionalPeerInfo()); + } + + private void updateAllInfo() { + if (adapter == null) { + return; + } + + versionLabel.setText("Version: %s".formatted(adapter.getVersion())); + userLabel.setText("User: %s(%s)".formatted(adapter.getUsername(), adapter.getUserId())); + rpcPortLabel.setText("RPC_PORT: %s".formatted(adapter.getRpcPort())); + gpgnetPortLabel.setText("GPGNET_PORT: %s".formatted(adapter.getGpgNetPort())); + lobbyPortLabel.setText("LOBBY_PORT: %s".formatted(adapter.getLobbyPort())); + + rpcServerStatus.setText("RPCServer: %s".formatted(adapter.getRpcServerStatus())); + rpcClientStatus.setText("RPCClient: %s".formatted(adapter.getRpcClientStatus())); + gpgnetServerStatus.setText("GPGNetServer: %s".formatted(adapter.getGpgNetServerStatus())); + gpgnetClientStatus.setText("GPGNetClient: %s".formatted(adapter.getGpgNetClientStatus())); + gameState.setText("GameState: %s".formatted(adapter.getGameState())); + + Platform.runLater(() -> { + PeerView currentlySelected = peerTable.getSelectionModel().getSelectedItem(); + + peerTable.getItems().setAll(adapter.getPeerInfoList()); + + if (currentlySelected != null) { + boolean found = false; + for (PeerView p : peerTable.getItems()) { + if (Objects.equals(p, currentlySelected)) { + peerTable.getSelectionModel().select(p); + setSelectedPeer(p); + found = true; + break; + } + } + if (!found) { + closePeerManagerPanel(); + } + } + }); + } + + private void startPeriodicUpdates() { + updateScheduler = Executors.newSingleThreadScheduledExecutor(); + updateScheduler.scheduleAtFixedRate(() -> { + Platform.runLater(this::updateAllInfo); + }, 0, 500, TimeUnit.MILLISECONDS); + } + + public void dispose() { + if (updateScheduler != null && !updateScheduler.isShutdown()) { + updateScheduler.shutdown(); + } + } + + public void close() { + Stage stage = (Stage) root.getScene().getWindow(); + stage.close(); + dispose(); + } + + private static void runOnUIThread(Runnable runnable) { + if (Platform.isFxApplicationThread()) { + runnable.run(); + } else { + Platform.runLater(runnable); + } + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/CandidateUtil.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/CandidateUtil.java index 3c399e3..51d861b 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/util/CandidateUtil.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/CandidateUtil.java @@ -2,22 +2,40 @@ import com.faforever.iceadapter.ice.CandidatePacket; import com.faforever.iceadapter.ice.CandidatesMessage; +import org.ice4j.Transport; +import org.ice4j.TransportAddress; +import org.ice4j.ice.*; + import java.util.ArrayList; import java.util.Collections; import java.util.List; -import org.ice4j.Transport; -import org.ice4j.TransportAddress; -import org.ice4j.ice.Agent; -import org.ice4j.ice.CandidateType; -import org.ice4j.ice.Component; -import org.ice4j.ice.IceMediaStream; -import org.ice4j.ice.LocalCandidate; -import org.ice4j.ice.RemoteCandidate; public class CandidateUtil { public static int candidateIDFactory = 0; + private static CandidatePacket createCandidatePacket(Agent agent, LocalCandidate localCandidate) { + String relAddr = null; + int relPort = 0; + + if (localCandidate.getRelatedAddress() != null) { + relAddr = localCandidate.getRelatedAddress().getHostAddress(); + relPort = localCandidate.getRelatedAddress().getPort(); + } + + return new CandidatePacket( + localCandidate.getFoundation(), + localCandidate.getTransportAddress().getTransport().toString(), + localCandidate.getPriority(), + localCandidate.getTransportAddress().getHostAddress(), + localCandidate.getTransportAddress().getPort(), + localCandidate.getType(), + agent.getGeneration(), + String.valueOf(candidateIDFactory++), + relAddr, + relPort); + } + public static CandidatesMessage packCandidates( int srcId, int destId, @@ -28,32 +46,15 @@ public static CandidatesMessage packCandidates( boolean allowRelay) { final List candidatePackets = new ArrayList<>(); - for (LocalCandidate localCandidate : component.getLocalCandidates()) { - String relAddr = null; - int relPort = 0; - - if (localCandidate.getRelatedAddress() != null) { - relAddr = localCandidate.getRelatedAddress().getHostAddress(); - relPort = localCandidate.getRelatedAddress().getPort(); - } - - CandidatePacket candidatePacket = new CandidatePacket( - localCandidate.getFoundation(), - localCandidate.getTransportAddress().getTransport().toString(), - localCandidate.getPriority(), - localCandidate.getTransportAddress().getHostAddress(), - localCandidate.getTransportAddress().getPort(), - localCandidate.getType(), - agent.getGeneration(), - String.valueOf(candidateIDFactory++), - relAddr, - relPort); - - if (isAllowedCandidate(allowHost, allowReflexive, allowRelay, localCandidate.getType())) { - candidatePackets.add(candidatePacket); + List prePackets = component.getLocalCandidates() + .stream() + .map(candidate -> createCandidatePacket(agent, candidate)) + .toList(); + for (CandidatePacket packet : prePackets) { + if (isAllowedCandidate(allowHost, allowReflexive, allowRelay, packet.type())) { + candidatePackets.add(packet); } } - Collections.sort(candidatePackets); return new CandidatesMessage(srcId, destId, agent.getLocalPassword(), agent.getLocalUfrag(), candidatePackets); @@ -68,8 +69,9 @@ public static void unpackCandidates( boolean allowReflexive, boolean allowRelay) { // Set candidates + String ufrag = remoteCandidatesMessage.ufrag(); mediaStream.setRemotePassword(remoteCandidatesMessage.password()); - mediaStream.setRemoteUfrag(remoteCandidatesMessage.ufrag()); + mediaStream.setRemoteUfrag(ufrag); remoteCandidatesMessage.candidates().stream() .sorted() // just in case some ICE adapter implementation did not sort it yet @@ -96,11 +98,11 @@ public static void unpackCandidates( RemoteCandidate remoteCandidate = new RemoteCandidate( mainAddress, component, - remoteCandidatePacket - .type(), // Expected to not return LOCAL or STUN (old names for host and srflx) + remoteCandidatePacket.type(), // Expected to not return LOCAL or STUN (old names for host and srflx) remoteCandidatePacket.foundation(), remoteCandidatePacket.priority(), - relatedCandidate); + relatedCandidate, + ufrag); if (isAllowedCandidate(allowHost, allowReflexive, allowRelay, remoteCandidate.getType())) { component.addRemoteCandidate(remoteCandidate); @@ -109,13 +111,56 @@ public static void unpackCandidates( }); } - private static boolean isAllowedCandidate( - boolean allowHost, boolean allowReflexive, boolean allowRelay, CandidateType candidateType) { + public static String infoCandidate(CandidatePair pair) { + if (pair == null) { + return null; + } + return """ + Local Candidate: + Type: %s + Transport: %s + Address: %s:%d + Priority: %d + Foundation: %s + + Remote Candidate: + Type: %s + Transport: %s + Address: %s:%d + Priority: %d + Foundation: %s + + Priority: %d + Nominated: %s + State: %s + """.formatted( + pair.getLocalCandidate().getType(), + pair.getLocalCandidate().getTransport(), + pair.getLocalCandidate().getTransportAddress().getHostAddress(), + pair.getLocalCandidate().getTransportAddress().getPort(), + pair.getLocalCandidate().getPriority(), + pair.getLocalCandidate().getFoundation(), + pair.getRemoteCandidate().getType(), + pair.getLocalCandidate().getTransport(), + pair.getRemoteCandidate().getTransportAddress().getHostAddress(), + pair.getRemoteCandidate().getTransportAddress().getPort(), + pair.getRemoteCandidate().getPriority(), + pair.getRemoteCandidate().getFoundation(), + pair.getPriority(), + pair.isNominated(), + pair.getState() + ); + } + + private static boolean isAllowedCandidate(boolean allowHost, + boolean allowReflexive, + boolean allowRelay, + CandidateType candidateType) { // Candidate types LOCAL and STUN can never occur as they are deprecated and not used boolean isAllowedHostCandidate = allowHost && candidateType == CandidateType.HOST_CANDIDATE; boolean isAllowedReflexiveCandidate = allowReflexive && (candidateType == CandidateType.SERVER_REFLEXIVE_CANDIDATE - || candidateType == CandidateType.PEER_REFLEXIVE_CANDIDATE); + || candidateType == CandidateType.PEER_REFLEXIVE_CANDIDATE); boolean isAllowedRelayCandidate = allowRelay && candidateType == CandidateType.RELAYED_CANDIDATE; return isAllowedHostCandidate || isAllowedReflexiveCandidate || isAllowedRelayCandidate; diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/DatagramSocketUtils.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/DatagramSocketUtils.java new file mode 100644 index 0000000..fba813a --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/DatagramSocketUtils.java @@ -0,0 +1,46 @@ +package com.faforever.iceadapter.util; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.net.DatagramSocket; +import java.net.SocketException; + +@UtilityClass +@Slf4j +public class DatagramSocketUtils { + public static final int MAX_SIZE_PACKET = /* assumed MTU */ 1500 - /* IPv4 header */ 20 - /* UDP header */ 8; + + public void resizeBuffer(DatagramSocket socket) { + try { + socket.setReceiveBufferSize(MAX_SIZE_PACKET); + socket.setSendBufferSize(MAX_SIZE_PACKET); + } catch (SocketException e) { + if (!socket.isClosed()) { + log.error("Failed to resize socket buffer on {}", MAX_SIZE_PACKET, e); + } + } + } + + public boolean isStunPacket(byte[] data, int length) { + if (length < 2) { + return false; + } + int type = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF); + return (type == 0x0000) || // Maybe keep-alive/misfire + (type == 0x0001) || // Binding Request + (type == 0x0101) || // Binding Response + (type == 0x0115) || // Shared Secret Request + (type == 0x0116) || // Shared Secret Response + (type == 0x0002); // Binding Indication + } + + public static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X ", b)); + } + return sb.toString().trim(); + } + +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/ExecutorHolder.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/ExecutorHolder.java index 8a74fef..c359c6b 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/util/ExecutorHolder.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/ExecutorHolder.java @@ -1,12 +1,26 @@ package com.faforever.iceadapter.util; +import com.faforever.iceadapter.services.impl.IceAsyncImpl; +import lombok.experimental.UtilityClass; + import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import lombok.experimental.UtilityClass; +import java.util.concurrent.ScheduledExecutorService; +/** + * Used in {@link IceAsyncImpl}. We have to use Executors.defaultThreadFactory() since Ice4J is not allowed to use virtual threads in Java 21. + */ @UtilityClass public class ExecutorHolder { + + private final ExecutorService executorService = Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory()); + private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(100, Executors.defaultThreadFactory()); + public ExecutorService getExecutor() { - return Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory()); + return executorService; + } + + public ScheduledExecutorService getScheduledExecutor() { + return scheduledExecutorService; } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/IceUtils.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/IceUtils.java new file mode 100644 index 0000000..c4b2a1f --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/IceUtils.java @@ -0,0 +1,21 @@ +package com.faforever.iceadapter.util; + +import lombok.experimental.UtilityClass; +import org.ice4j.ice.Component; +import org.ice4j.ice.IceMediaStream; + +import java.util.Optional; + +@UtilityClass +public class IceUtils { + + public Optional getFirstComponent(IceMediaStream mediaStream) { + if (mediaStream == null) { + return Optional.empty(); + } + + return mediaStream.getComponents() + .stream() + .findFirst(); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/LockUtil.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/LockUtil.java index c4f2ebc..fab939e 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/util/LockUtil.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/LockUtil.java @@ -1,8 +1,10 @@ package com.faforever.iceadapter.util; -import java.util.concurrent.locks.Lock; import lombok.experimental.UtilityClass; +import java.util.concurrent.Callable; +import java.util.concurrent.locks.Lock; + @UtilityClass public class LockUtil { public void executeWithLock(Lock lock, Runnable task) { @@ -13,4 +15,15 @@ public void executeWithLock(Lock lock, Runnable task) { lock.unlock(); } } + + public T executeWithLock(Lock lock, Callable task) { + lock.lock(); + try { + return task.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } + } } diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingUtil.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingUtil.java new file mode 100644 index 0000000..baa3e65 --- /dev/null +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingUtil.java @@ -0,0 +1,28 @@ +package com.faforever.iceadapter.util; + +import com.faforever.iceadapter.IceAdapter; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.experimental.UtilityClass; + +import java.util.OptionalDouble; +import java.util.concurrent.CompletableFuture; + +@UtilityClass +public class PingUtil { + + private final LoadingCache> hostRTTCache = CacheBuilder.newBuilder() + .build(new CacheLoader<>() { + @Override + public CompletableFuture load(String host) { + return PingWrapper.getLatency(host, IceAdapter.getPingCount()) + .thenApply(OptionalDouble::of) + .exceptionally(ex -> OptionalDouble.empty()); + } + }); + + public CompletableFuture getLatency(String host) { + return hostRTTCache.getUnchecked(host); + } +} diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingWrapper.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingWrapper.java index 00ca8df..9693780 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingWrapper.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/PingWrapper.java @@ -1,14 +1,14 @@ package com.faforever.iceadapter.util; -import com.faforever.iceadapter.IceAdapter; import com.google.common.io.CharStreams; +import lombok.extern.slf4j.Slf4j; + import java.io.IOException; import java.io.InputStreamReader; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.regex.Matcher; import java.util.regex.Pattern; -import lombok.extern.slf4j.Slf4j; /* * A wrapper around calling the system `ping` executable to query the latency of a host. @@ -28,7 +28,9 @@ public static CompletableFuture getLatency(String address, Integer count Pattern output_pattern; if (System.getProperty("os.name").startsWith("Windows")) { - process = new ProcessBuilder("ping", "-n", count.toString(), address).start(); + // Force English output using code page 437 + String command = String.format("chcp 437 > NUL && ping -n %d %s", count, address); + process = new ProcessBuilder("cmd", "/c", command).start(); output_pattern = WINDOWS_OUTPUT_PATTERN; } else { process = new ProcessBuilder("ping", "-c", count.toString(), address).start(); @@ -50,14 +52,13 @@ public static CompletableFuture getLatency(String address, Integer count log.debug("Pinged {} with an RTT of {}", address, result); return result; } else { - log.warn("Failed to ping {}", address); - throw new RuntimeException("Failed to contact the host"); + log.warn("Failed to ping {}: output='{}'", address, output); + throw new RuntimeException("Failed to contact the host or parse ping output"); } } catch (InterruptedException | IOException | RuntimeException e) { throw new CompletionException(e); } - }, - IceAdapter.getExecutor()); + }); } catch (IOException e) { CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(e); diff --git a/ice-adapter/src/main/java/com/faforever/iceadapter/util/TrayIcon.java b/ice-adapter/src/main/java/com/faforever/iceadapter/util/TrayIcon.java index c430076..83244a9 100644 --- a/ice-adapter/src/main/java/com/faforever/iceadapter/util/TrayIcon.java +++ b/ice-adapter/src/main/java/com/faforever/iceadapter/util/TrayIcon.java @@ -1,25 +1,18 @@ package com.faforever.iceadapter.util; -import com.faforever.iceadapter.IceAdapter; +import com.faforever.iceadapter.LogoUtils; import com.faforever.iceadapter.debug.Debug; -import com.faforever.iceadapter.debug.DebugWindow; -import com.faforever.iceadapter.debug.InfoWindow; -import java.awt.AWTException; -import java.awt.Image; -import java.awt.SystemTray; +import com.faforever.iceadapter.ui.InfoWindow; +import lombok.extern.slf4j.Slf4j; + +import javax.swing.*; +import java.awt.*; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; -import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.CompletableFuture; -import javax.imageio.ImageIO; -import javax.swing.SwingUtilities; -import lombok.extern.slf4j.Slf4j; @Slf4j public class TrayIcon { - public static final String FAF_LOGO_FILE = "faf-logo.png"; private static volatile java.awt.TrayIcon trayIcon; public static void create() { @@ -28,52 +21,46 @@ public static void create() { return; } - Image fafLogo = null; - try (final InputStream imageStream = TrayIcon.class.getClassLoader().getResourceAsStream(FAF_LOGO_FILE)) { - if (imageStream == null) { - log.error("Couldn't find '{}' in resource folder", FAF_LOGO_FILE); - return; - } - fafLogo = ImageIO.read(imageStream); - } catch (IOException e) { - log.error("Couldn't load FAF tray icon logo from resource folder", e); + Image fafLogo = LogoUtils.getLogo(); + if (fafLogo == null) { return; } + Dimension dimension = SystemTray.getSystemTray().getTrayIconSize(); fafLogo = fafLogo.getScaledInstance( - new java.awt.TrayIcon(fafLogo).getSize().width, - new java.awt.TrayIcon(fafLogo).getSize().height, + dimension.width, + dimension.height, Image.SCALE_SMOOTH); trayIcon = new java.awt.TrayIcon(fafLogo, "FAForever Connection ICE Adapter"); trayIcon.addMouseListener(new MouseListener() { @Override - public void mouseClicked(MouseEvent mouseEvent) {} + public void mouseClicked(MouseEvent mouseEvent) { + } @Override public void mousePressed(MouseEvent mouseEvent) { - CompletableFuture.runAsync( - () -> { - if (InfoWindow.INSTANCE == null) { - log.info("Launching ICE adapter debug window"); - Debug.ENABLE_INFO_WINDOW = true; - DebugWindow.launchApplication(); - } else { - InfoWindow.INSTANCE.show(); - } - }, - IceAdapter.getExecutor()); + if (InfoWindow.INSTANCE == null) { + log.info("Launching ICE adapter debug window"); + Debug.ENABLE_INFO_WINDOW = true; + InfoWindow.launch(); + } else { + InfoWindow.INSTANCE.showWindow(); + } } @Override - public void mouseReleased(MouseEvent mouseEvent) {} + public void mouseReleased(MouseEvent mouseEvent) { + } @Override - public void mouseEntered(MouseEvent mouseEvent) {} + public void mouseEntered(MouseEvent mouseEvent) { + } @Override - public void mouseExited(MouseEvent mouseEvent) {} + public void mouseExited(MouseEvent mouseEvent) { + } }); try { @@ -95,7 +82,16 @@ public static void showMessage(String message) { } public static void close() { - SystemTray.getSystemTray().remove(trayIcon); + if (isTrayIconSupported() && trayIcon != null) { + try { + SystemTray.getSystemTray().remove(trayIcon); + log.info("Tray icon removed"); + } catch (Exception e) { + log.error("Error removing tray icon", e); + } finally { + trayIcon = null; + } + } } public static boolean isTrayIconSupported() { diff --git a/ice-adapter/src/main/resources/IceServerTable.fxml b/ice-adapter/src/main/resources/IceServerTable.fxml new file mode 100644 index 0000000..7f202e8 --- /dev/null +++ b/ice-adapter/src/main/resources/IceServerTable.fxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ice-adapter/src/main/resources/debugWindow.fxml b/ice-adapter/src/main/resources/debugWindow.fxml deleted file mode 100644 index 13ae1bd..0000000 --- a/ice-adapter/src/main/resources/debugWindow.fxml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - -