1+ package com .osiris .desku .ui ;
2+
3+ import com .osiris .desku .App ;
4+ import com .osiris .desku .ui .utils .Event ;
5+ import com .osiris .jlib .logger .AL ;
6+ import org .java_websocket .WebSocket ;
7+ import org .java_websocket .handshake .ClientHandshake ;
8+ import org .java_websocket .server .WebSocketServer ;
9+ import org .jetbrains .annotations .Nullable ;
10+
11+ import java .net .InetSocketAddress ;
12+ import java .time .Duration ;
13+ import java .util .ArrayList ;
14+ import java .util .List ;
15+ import java .util .concurrent .CopyOnWriteArrayList ;
16+ import java .util .concurrent .ExecutorService ;
17+ import java .util .concurrent .Executors ;
18+ import java .util .concurrent .atomic .AtomicInteger ;
19+ import java .util .function .Consumer ;
20+
21+ /**
22+ * A WebSocket server that sends JavaScript code to be executed in the browser
23+ * and returns its execution result.
24+ */
25+ public class JSWebSocketServer extends WebSocketServer {
26+ public final String domain ;
27+ public final int port ;
28+ public final List <PendingJavaScriptResult > javaScriptCallbacks = new CopyOnWriteArrayList <>();
29+ public final AtomicInteger counter = new AtomicInteger ();
30+ public transient long lastPingReceivedMs = System .currentTimeMillis ();
31+ public final Thread timeoutCheckerThread = new Thread (() -> {
32+ // Kind of wasteful to have a thread pretty much always in sleep, virtual threads would be perfect here
33+ // TODO however not sure if we lose a lot of backwards support of android versions if we upgrade the java version
34+ int maxSeconds = 30 ;
35+ long maxMillis = Duration .ofSeconds (maxSeconds ).toMillis ();
36+ var toRemove = new ArrayList <PendingJavaScriptResult >(0 );
37+ while (true ){
38+ try {
39+ Thread .sleep (1000 );
40+ long now = System .currentTimeMillis ();
41+ synchronized (javaScriptCallbacks ){
42+ toRemove .clear ();
43+ for (PendingJavaScriptResult javaScriptCallback : javaScriptCallbacks ) {
44+ if (javaScriptCallback .isPermanent ) continue ; // Skip permanent callbacks
45+ if (now - javaScriptCallback .msCreated > maxMillis ){
46+ System .err .println ("This pending javascript result never got a response from the browser/client within " +maxSeconds +" seconds,\n " +
47+ "thus it was removed and will never complete which might result in unexpected behavior.\n " +
48+ "This could mean the client closed the page during something running or something else went entirely wrong.\n " +
49+ "This cannot be caused by wrong/broken javascript code provided by you since that is within a try/catch block." );
50+ System .err .println ("PendingJavaScriptResult-ID == " +javaScriptCallback .id );
51+ new Exception ().printStackTrace ();
52+ toRemove .add (javaScriptCallback );
53+ }
54+ }
55+ javaScriptCallbacks .removeAll (toRemove );
56+ }
57+ } catch (InterruptedException e ){
58+ break ;
59+ } catch (Exception e ) {
60+ System .err .println ("Something went wrong in logic of timeoutCheckerThread!" );
61+ e .printStackTrace ();
62+ }
63+ }
64+ });
65+ {
66+ timeoutCheckerThread .setName ("JavaScriptExecutionTimeoutCheckerThread" );
67+ }
68+
69+ public JSWebSocketServer (String domain , int port ) {
70+ super (new InetSocketAddress (domain , port ));
71+ start ();
72+ this .domain = domain ;
73+ this .port = port ;
74+ timeoutCheckerThread .start ();
75+ }
76+
77+ // public String jsStartWebSocketClient(String serverDomain, int serverPort) {
78+ // String url = "ws://" + serverDomain + ":" + serverPort;
79+ // String jsCode = "try{ var webSocketServer = new WebSocket('" + url + "');\n" +
80+ // "window.webSocketServer = webSocketServer;\n" + // Make globally accessible
81+ // "console.log(\"Connecting to WebSocket server...\")\n" +
82+ // "\n" +
83+ // " webSocketServer.addEventListener(\"open\", (event) => {\n" +
84+ // " console.log('WebSocket connection established.');\n" +
85+ // " });\n" +
86+ // "\n" +
87+ // " webSocketServer.addEventListener(\"message\", (event) => {\n" +
88+ // " // Receive a message from the server\n" +
89+ // " var receivedMessage = event.data;\n" +
90+ // (App.isInDepthDebugging ? " console.log('Received message from server:\\n', receivedMessage);\n" : "") +
91+ // " eval(event.data);\n" +
92+ // " });\n" +
93+ // "\n" +
94+ // " webSocketServer.addEventListener(\"close\", (event) => {\n" +
95+ // " console.log('WebSocket connection closed.');\n" +
96+ // " });\n" +
97+ // "} catch (e) {console.error(e)}\n";
98+ // return jsCode;
99+ // }
100+
101+ public String jsStartWebSocketClient (String serverDomain , int serverPort ) {
102+ String url = "ws://" + serverDomain + ":" + serverPort ;
103+ String jsCode =
104+ "try {\n " +
105+ " var webSocketServer;\n " +
106+ " var reconnectAttempts = 0;\n " +
107+ " var maxReconnectInterval = 3000; // 3 seconds cap\n " +
108+ "\n " +
109+ " function connectWebSocket() {\n " +
110+ " console.log(\" Connecting to WebSocket server...\" );\n " +
111+ " webSocketServer = new WebSocket('" + url + "');\n " +
112+ " window.webSocketServer = webSocketServer;\n " + // Make globally accessible
113+ "\n " +
114+ " webSocketServer.addEventListener(\" open\" , () => {\n " +
115+ " console.log('WebSocket connection established.');\n " +
116+ " reconnectAttempts = 0; // Reset on successful connection\n " +
117+ " });\n " +
118+ "\n " +
119+ " webSocketServer.addEventListener(\" message\" , (event) => {\n " +
120+ " var receivedMessage = event.data;\n " +
121+ (App .isInDepthDebugging ? " console.log('Received message from server:\\ n', receivedMessage);\n " : "" ) +
122+ " eval(receivedMessage);\n " +
123+ " });\n " +
124+ "\n " +
125+ " webSocketServer.addEventListener(\" close\" , () => {\n " +
126+ " console.log('WebSocket connection closed. Attempting to reconnect...');\n " +
127+ " scheduleReconnect();\n " +
128+ " });\n " +
129+ "\n " +
130+ " webSocketServer.addEventListener(\" error\" , (error) => {\n " +
131+ " console.error('WebSocket error:', error);\n " +
132+ " webSocketServer.close(); // Force close to trigger reconnect\n " +
133+ " });\n " +
134+ " }\n " +
135+ "\n " +
136+ " function scheduleReconnect() {\n " +
137+ " let reconnectDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectInterval);\n " +
138+ " console.log(`Reconnecting in ${reconnectDelay / 1000} seconds...`);\n " +
139+ " setTimeout(connectWebSocket, reconnectDelay);\n " +
140+ " reconnectAttempts++;\n " +
141+ " }\n " +
142+ "\n " +
143+ " connectWebSocket(); // Initial connection\n " +
144+ "} catch (e) { console.error(e); }\n " ;
145+
146+ return jsCode ;
147+ }
148+
149+ /**
150+ * Executes JavaScript (JS) code now and returns directly.
151+ * The {@link Event} gets triggered once JS code execution finishes/fails.<br>
152+ * <br>
153+ * Do not worry if you add an action/listener to the event after it was triggered, because this is a {@link Event}
154+ * and your listener will still be run. <br>
155+ * <br>
156+ * Since we use JavaScript WebSockets, its ensured that the sent JS code is executed in an orderly, synchronous fashion.
157+ * Meaning that the JS code in the second call of this method, gets executed after the code in the first call finished/failed.<br>
158+ * <br>
159+ *
160+ */
161+ public void executeJavaScript (UI ui , Event <JavaScriptResult > event , String code ){
162+ Registration registration = addPermanentCallback (ui , code , result -> {
163+ event .execute (result );
164+ });
165+
166+ registration .pendingJavaScriptResult .isPermanent = false ;
167+ code = registration .jsCode ; // Registration adds some extra js code
168+ code = "// PendingJavaScriptResult-ID == " +registration .pendingJavaScriptResult .id +"\n " + code ;
169+ broadcast (code ); // Execute code in client
170+ }
171+
172+ public static class Registration {
173+ public String jsCode ;
174+ public PendingJavaScriptResult pendingJavaScriptResult ;
175+
176+ public Registration (String jsCode , PendingJavaScriptResult pendingJavaScriptResult ) {
177+ this .jsCode = jsCode ;
178+ this .pendingJavaScriptResult = pendingJavaScriptResult ;
179+ }
180+ }
181+
182+ /**
183+ * Returns new JS (JavaScript) code, that when executed in client browser
184+ * results in onJSFunctionExecuted being executed. <br><br>
185+ * <p>
186+ * It wraps around your jsCode and adds callback related stuff, as well as error handling. <br><br>
187+ * <p>
188+ * Its a permanent callback, because the returned JS code can be executed multiple times
189+ * which results in onJSFunctionExecuted or onJSFuntionFailed to get executed multiple times too. <br><br>
190+ * <p>
191+ * Also appends a check to the JS code that sets message="" if it was null/undefined.<br><br>
192+ *
193+ * @param jsCode modify the message variable in the provided JS (JavaScript) code to send information from JS to Java.
194+ * Your JS code could look like this: <br>
195+ * message = 'first second third etc...';
196+ * @param onFinished executed when the provided jsCode executes successfully. String contains the message variable that can be set in your jsCode.
197+ * Or executed when the provided jsCode threw an exception/failed. String contains details about the exception/error in that case.
198+ */
199+ public Registration addPermanentCallback (UI ui , String jsCode , Consumer <JavaScriptResult > onFinished ) {
200+ // 1. execute client JS
201+ // 2. execute callback in java with params from client JS
202+ // 3. return success to client JS and execute it
203+ int id = counter .getAndIncrement ();
204+ PendingJavaScriptResult pendingJavaScriptResult ;
205+ synchronized (javaScriptCallbacks ) {
206+ pendingJavaScriptResult = new PendingJavaScriptResult (id , onFinished , ui );
207+ javaScriptCallbacks .add (pendingJavaScriptResult );
208+ }
209+ return new Registration ("var message = '';\n " + // Separated by space
210+ "var error = null;\n " +
211+ "try{" + jsCode + "\n " +
212+ "if(message==null) message = '';\n " +
213+ "} catch (e) { error = e; console.error(e);}\n " +
214+ jsClientSendWebSocketMessage ("(error == null ? ('" + id + " '+message) : ('!" + id + " '+error))" ), pendingJavaScriptResult );
215+ }
216+
217+ public String jsClientSendWebSocketMessage (String message ) {
218+ return "if(webSocketServer.readyState != 1) {\n " +
219+ //"console.log(`Frontend and Backend are connected!`)\n" + // 1 == OPEN
220+ " webSocketServer.addEventListener(\" open\" , (event) => {\n " +
221+ " webSocketServer.send(" + message + ");\n " +
222+ " });\n " +
223+ "} else webSocketServer.send(" + message + ");\n " ;
224+ }
225+
226+ @ Override
227+ public void onOpen (WebSocket conn , ClientHandshake handshake ) {
228+
229+ }
230+
231+ @ Override
232+ public void onClose (WebSocket conn , int code , String reason , boolean remote ) {
233+ timeoutCheckerThread .interrupt ();
234+ AL .info ("Closed websocket server probably due to inactivity " +this .domain +":" +this .port +" - " +this .toString ());
235+ }
236+
237+ @ Override
238+ public void onMessage (WebSocket conn , String message ) {
239+ // message format: id <message>
240+ int iFirstSpace = message .indexOf (" " );
241+ boolean isError = message .startsWith ("!" );
242+ int id = isError ? Integer .parseInt (message .substring (1 , iFirstSpace )) :
243+ Integer .parseInt (message .substring (0 , iFirstSpace ));
244+ int iPendingResultToRemove = -1 ;
245+
246+ // To avoid deadlocks do not execute ui.access inside a synchronized javaScriptCallbacks block
247+ @ Nullable PendingJavaScriptResult pendingResult = null ;
248+ synchronized (javaScriptCallbacks ) {
249+ for (int i = 0 ; i < javaScriptCallbacks .size (); i ++) {
250+ PendingJavaScriptResult pr = javaScriptCallbacks .get (i );
251+ if (pr .id == id ) {
252+ pendingResult = pr ;
253+ if (!pendingResult .isPermanent ) iPendingResultToRemove = i ;
254+ break ;
255+ }
256+ }
257+ if (iPendingResultToRemove != -1 )
258+ javaScriptCallbacks .remove (iPendingResultToRemove );
259+ }
260+
261+ // Execute the pending javascript result event in the correct UI context
262+ if (pendingResult != null ){
263+ final var finalPendingResult = pendingResult ;
264+ if (!isError ) // message looks like this: "!3 Error details..." 3 is the id and can be any number
265+ pendingResult .ui .access (() -> finalPendingResult .onFinished .accept (new JavaScriptResult (message .substring (iFirstSpace + 1 ), true )));
266+ else // message looks like this: "3 first second etc..." 3 is the id and can be any number, the rest can be any data or even empty
267+ pendingResult .ui .access (() -> finalPendingResult .onFinished .accept (new JavaScriptResult (message .substring (iFirstSpace + 1 ), false )));
268+ }
269+ }
270+
271+ @ Override
272+ public void onError (WebSocket conn , Exception ex ) {
273+
274+ }
275+
276+ @ Override
277+ public void onStart () {
278+ }
279+ }
0 commit comments