====== Working on WASM HomeCtrl implementation ====== {{tag>dev cpp wasm webassembly homectrl qt}} Going back to the WASM implementation of the HomeCtrl tool now, since I just failed with my initial experiment with Kivy. If I can get the WASM version to work at least, then I might be able to use that from my phone for a while (until I find a better solution?) ====== ====== Youtube video for this article available at: ;#; {{ youtube>mLu5moy2DTY?large }} ;#; I spent some time to get the emscripten compilation back on rails, and also updated the RequestManager to support async post requests (in addtion to async get requests), but eventually I could start the updated version of the homectrl app in firefox. Yet, nothing will be retrieved from the servers as I get the following errors: {{ blog:2023:006_mixed_content_block.png }} This is due to the **mixed content** limitation preventing us from downloading stuff from http urls while we are on an https website (a good thing of course!). To fix this, I think the most appropriate option now is really to start providing the access to the servers through my official webserver (with https) so let's set this up. I have thus updated the rules in my api.nervtech.org subdomain to redirect to the raspberries as needed: location ~ ^/homectrl(.*)$ { rewrite ^/homectrl/(.*) /$1 break; proxy_pass http://192.168.0.9:8082; } location ~ ^/swimctrl(.*)$ { rewrite ^/swimctrl/(.*) /$1 break; proxy_pass http://192.168.0.8:8082; } Now restarting the nginx server: cd /mnt/shares/master/containers/nginx_server docker-compose down docker-compose up -d Then I have to use the updated server locations in the homectrl config file: # This is the main configuration file for HomeView servers: SwimCtrl: # base_url: http://localhost # base_url: http://192.168.0.8:8082 base_url: https://api.nervtech.org/swimctrl HomeCtrl: # base_url: http://192.168.0.9:8082 base_url: https://api.nervtech.org/homectrl Testing with the desktop version of homeview: nvp homeview And this is still working just fine: {{ blog:2023:007_homeview_from_nervtech.org.png }} Next I must rebuild the wasm version of homeview to update the config.yml contained in the data. And after that we can give it another try with: nvp nvl_serve_homeview And crap... 😂 Still not working...! But this time due to missing CORS headers: {{ blog:2023:008_homeview_missing_cors.png }} So I simply added a wildcard CORS on the homectrl/swimctrl locations for now: location ~ ^/homectrl(.*)$ { add_header 'Access-Control-Allow-Origin' '*'; rewrite ^/homectrl/(.*) /$1 break; proxy_pass http://192.168.0.9:8082; } location ~ ^/swimctrl(.*)$ { add_header 'Access-Control-Allow-Origin' '*'; rewrite ^/swimctrl/(.*) /$1 break; proxy_pass http://192.168.0.8:8082; } And after restarting the nginx server, this time, starting the WASM version of Homeviw will work! Yeeeeppeeee 🥳!! {{ blog:2023:009_homeview_get_working.png }} hmmmm 🤔... it seems that the GET requests are working, but not the post requests, for these I still seem to get a CORS issue. Could be because I need to explicitly enable support for both GET and POST methods ? let's see... => Using this page as reference: https://serverfault.com/questions/162429/how-do-i-add-access-control-allow-origin-in-nginx Now updated the nginx rules again: location ~ ^/homectrl(.*)$ { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Methods' 'GET, POST'; rewrite ^/homectrl/(.*) /$1 break; proxy_pass http://192.168.0.9:8082; } location ~ ^/swimctrl(.*)$ { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Methods' 'GET, POST'; rewrite ^/swimctrl/(.*) /$1 break; proxy_pass http://192.168.0.8:8082; } Yet, this doesn't seem to be enough since I still get a CORS error for the POST request. But now I found [[https://stackoverflow.com/questions/43871637/no-access-control-allow-origin-header-is-present-on-the-requested-resource-whe|this page]]: so, it could be the browser is also sending an OPTIONS request, and we need more stuff setup on the server side, so updating again to this now: location ~ ^/homectrl(.*)$ { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; rewrite ^/homectrl/(.*) /$1 break; proxy_pass http://192.168.0.9:8082; } location ~ ^/swimctrl(.*)$ { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; rewrite ^/swimctrl/(.*) /$1 break; proxy_pass http://192.168.0.8:8082; } And still not working... hmmm... 🤔 Now I'm wondering if that' could maybe be related to the python server I use to run the app, which has: def send_my_headers(self): """Senf my headers""" self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") Nope! But now checking the log from the homectrl Flask app, it seems that I'm actually sending a bunch of GET requests to the /handle endpoint here (and that's not supported at all, only POST is available for that endpoint): So this seems to indicate that... I'm not actually sending POST requests in my Request::post_async implementation ? 🤔. Let's check. Arrfff... Stupid me: I also had to update the **CustomResponseHandler** to keep sending POST requests instead of GET: void CustomResponseHandler::retry_request() { if (_maxRetries == 0 || _count < _maxRetries) { // We may retry the request auto* rman = RequestManager::instance(); logDEBUG("Sending async request..."); if (_isPost) { rman->raw_post_async(_url.c_str(), _jsonData, this, &_headers, _timeout); } else { rman->raw_get_async(_url.c_str(), this, &_headers, _timeout); } } else { logWARN("Max retry count reached cannot get request result from {}", _url); _handler->on_response(nullptr); } } But of course, this is still not working 🤕... So now I'm starting to think maybe it's directly in the Flask application itself that I should add the header on the response ? Let's see... * Also, now I get the status code 415 instead of 405, and in the homectrl log, I see that I'm making proper POST requests this time: 2023/07/11 22:30:02 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:02] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:02 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:02] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:02 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:02] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 - 2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 - Now checking with curl: $ curl -I https://api.nervtech.org/homectrl/state HTTP/1.1 200 OK Server: nginx/1.21.6 Date: Tue, 11 Jul 2023 20:49:41 GMT Content-Type: application/json Content-Length: 737 Connection: keep-alive Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type And for the POST command: $ curl -X POST -H 'Content-Type: application/json' -d '{"device":"Toggle Solar Valve","action":"trigger", "auth":"xxx"}' https://api.nervtech.org/homectrl/handle => Ok, great, but I cannot see the headers in this case. But anyway, checking the network tab from firefox I see this: {{ blog:2023:010_homeview_invalid_json.png }} So it seems I'm not setting the "Content-Type" header correctly here. And this seems confirmed on the header tab: {{ blog:2023:011_homeview_missing_header.png }} => let's check how I deal with the headers then. [//A few moments later...//] Feeeww, that was really tricky: in the end I need to set the CORS headers both in the flask server and in nginx, but then I have to override the headers on the server side, just don't ask me why 😅, but in the end here is all the dirty mess I got on my nginx config file: # cf. https://stackoverflow.com/questions/17423414/nginx-proxy-pass-subpaths-not-redirected location ~ ^/homectrl(.*)$ { # add_header 'Access-Control-Allow-Origin' 'https://nervtech.local:444'; # add_header 'Access-Control-Allow-Credentials' 'true'; # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; # add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; # if ($request_method ~* "(GET|POST)") { # add_header "Access-Control-Allow-Origin" "*"; # } # add_header 'Access-Control-Allow-Credentials' 'true'; # add_header 'Access-Control-Allow-Headers' 'Authorization,Accept,Origin,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'; # add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS,PUT,DELETE,PATCH'; if ($request_method = 'OPTIONS') { # add_header 'Access-Control-Max-Age' 1728000; # add_header "Access-Control-Allow-Origin" "*"; # add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; # add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; # # add_header 'Content-Type' 'text/plain charset=UTF-8'; # # add_header 'Content-Length' 0; # return 200; # cf. https://stackoverflow.com/questions/45986631/how-to-enable-cors-in-nginx-proxy-server add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Headers' 'Authorization,Accept,Origin,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'; add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS,PUT,DELETE,PATCH'; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain charset=UTF-8'; add_header 'Content-Length' 0; return 204; } rewrite ^/homectrl/(.*) /$1 break; proxy_pass http://192.168.0.9:8082; proxy_hide_header Access-Control-Allow-Origin; add_header 'Access-Control-Allow-Origin' '*' always; } And I'm not even out of the woods yet: now the post request seems to be accepted, but the JSON data it sends is corrupted, so I get a 400 status code 😪. But I think I see why: the fetch.h documentation specify: {{ blog:2023:012_homeview_fetch_mem_data.png }} => Currently the pointer I'm providing is not valid until the end of the fetch (since this is an async operation): // Set the request body logDEBUG("Should send post data: '{}'", jsonData); attr.requestData = jsonData.c_str(); attr.requestDataSize = jsonData.size();/*//*/ To fix this, I should also keep track of the json data in a static map somehow before accessing it. So now doing something like this: // Store the request handler: U64 id = get_next_request_id(); attr.userData = (void*)id; auto& hmap = get_response_handler_map(); hmap.insert(std::make_pair(id, handler)); auto& dmap = get_request_data_map(); dmap.insert(std::make_pair(id, jsonData)); // Set the request body // Assign the pointer towards the referenced string data: attr.requestData = dmap[id].c_str(); attr.requestDataSize = jsonData.size(); // Run the fetch: emscripten_fetch(&attr, url); **Bingo**! Now I can finally send my POST requests from the wasm version of homeview, and this request is handled as expected, feeewwwww... that was hard 😳: ==== Storing credentials in local db ==== Next, we should consider the question of the credentials storage before I can make this app available on my public website: I think the easiest option here would simply be to store the credentials in a local file, and on emscripten side, writing to a file should transparently store the data in the browser indexed DB, right? Let's give this a try. Here is the initial version of the function I wrote to get the credentials from the user: void MainWindow::update_credentials() { logDEBUG("Should update the credentials here."); // Create the dialog window QDialog dialog; dialog.setWindowTitle("Login"); dialog.setMinimumWidth(300); // Create the form layout QFormLayout formLayout(&dialog); // Create labels and line edits for username and password QLabel usernameLabel("Username:"); QLineEdit usernameLineEdit; QLabel passwordLabel("Password:"); QLineEdit passwordLineEdit; passwordLineEdit.setEchoMode(QLineEdit::Password); // Add labels and line edits to the form layout formLayout.addRow(&usernameLabel, &usernameLineEdit); formLayout.addRow(&passwordLabel, &passwordLineEdit); // Create the OK and Cancel buttons QPushButton okButton("OK"); QPushButton cancelButton("Cancel"); // Connect the OK button's clicked signal to accept the dialog QObject::connect(&okButton, &QPushButton::clicked, [&]() { auto username = usernameLineEdit.text().toStdString(); auto password = passwordLineEdit.text().toStdString(); dialog.accept(); logDEBUG("Got username: {}, password: {}", username.c_str(), password.c_str()); }); // Connect the Cancel button's clicked signal to reject the dialog QObject::connect(&cancelButton, &QPushButton::clicked, [&dialog]() { dialog.reject(); }); // Add the buttons to the form layout formLayout.addRow(&okButton, &cancelButton); // Show the dialog dialog.exec(); } Now let's save those credentials to file... QObject::connect(&okButton, &QPushButton::clicked, [&]() { auto username = usernameLineEdit.text().toStdString(); auto password = passwordLineEdit.text().toStdString(); dialog.accept(); logDEBUG("Got username: {}, password: {}", username.c_str(), password.c_str()); // Now saving the credentials to file: auto* state = StateManager::instance()->get_state(); String auth = format_string("%s_%s", username.c_str(), password.c_str()); state->setString("auth_token", auth); auto& app = NervApp::instance(); String stateFile = get_path(app.get_root_path(), "state.yml"); write_file(stateFile.c_str(), format_string("auth_token: %s", auth.c_str())); }); Arrffff... 😒 Here is something else now: the state is never saved when running with emscripten. I thought it would somehow save that in the browser IndexDB, but no. Ahhh, okay, now I understand: I just found [[https://stackoverflow.com/questions/54617194/how-to-save-files-from-c-to-browser-storage-with-emscripten|this page]] explaining that by default it is the MEMFS system which is used, we need to explicitly request **IDBFS**, so we need something like that: // EM_ASM is a macro to call in-line JavaScript code. EM_ASM( // Make a directory other than '/' FS.mkdir('/homeview'); // Then mount with IDBFS type FS.mount(IDBFS, {}, '/homeview'); // Then sync FS.syncfs(true, function (err) { // Error }); ); And of course... that's not working, and I now get this error message 🤣: {{ blog:2023:013_homeview_lidbfs.js.png }} I have now updated my linkflags for QT in emscripten to include that library: macro(get_emscripten_qt_linkflags link_fvar) # -s ALLOW_MEMORY_GROWTH=1 -s USE_ZLIB=1 -s USE_HARFBUZZ=1 -s USE_FREETYPE=1 # -s USE_LIBPNG=1 -s DISABLE_EXCEPTION_CATCHING=1 set(${link_fvar} "${${link_fvar}} -s WASM=1 -O2 --bind -pthread -sPTHREAD_POOL_SIZE=4 -s ASSERTIONS " ) set(${link_fvar} "${${link_fvar}} -s FETCH=1 -s WASM_BIGINT=1 -s MODULARIZE=1 -s EXPORT_NAME=createQtAppInstance" ) set(${link_fvar} "${${link_fvar}} -s EXPORTED_RUNTIME_METHODS=UTF16ToString,stringToUTF16 -s INITIAL_MEMORY=1GB" ) set(${link_fvar} "${${link_fvar}} -s FULL_ES2=1 -s USE_WEBGL2=1 -s NO_EXIT_RUNTIME=1 -s ERROR_ON_UNDEFINED_SYMBOLS=1" ) set(${link_fvar} "${${link_fvar}} -lidbfs.js") # Might need to include "--use-preload-plugins" here. # cf. https://emscripten.org/docs/tools_reference/emcc.html?highlight=preload endmacro() OK! Now the app is starting again... so tryig to save those credentials again... But still no damned state.yml file on restart 😡! This is really starting to piss me off now. Come on! aarrffff... I see, reading [[https://emscripten.org/docs/api_reference/Filesystem-API.html#FS.syncfs|this page]] it seems that we have to deal with the synchronization ourself here calling the **FS.syncfs()** function. So let's give it another try. Still investigating on this IndexedDB filesystem, now I can see when just starting the app that according to firefox I **do** have the state.yml file data stored: {{ blog:2023:014_homeview_state_stored.png }} So, should be on the loading part of the file that we have an issue ?: #ifdef __EMSCRIPTEN__ _storageDir = "/homeview"; #else _storageDir = app.get_root_path(); #endif String stateFile = get_path(_storageDir, "state.yml"); // Read the file: if (file_exists(stateFile)) { logDEBUG("Loading state file..."); auto* state = StateManager::instance()->get_state(); state->inherit(AnyMap::read_yaml_file(stateFile.c_str()).get()); } else { logDEBUG("No state file found at {}", stateFile.c_str()); } => Let's try to bypass the **file_exists()** call, and load the file directly! Crap... This will simply produce an exception and fail 😭: {{ blog:2023:015_homeview_cannot_load_state.yml.png }} So what am I missing here 🤔? ohhhhhh 😲 I think I might have another hint now: I added more debug outputs in the initial FS.syncFS() call: #ifdef __EMSCRIPTEN__ // EM_ASM is a macro to call in-line JavaScript code. EM_ASM( // Make a directory other than '/' FS.mkdir('/homeview'); // Then mount with IDBFS type FS.mount(IDBFS, {}, '/homeview'); // Then sync // true, FS.syncfs(function(err) { console.log("Done with initial FS.syncFS()!"); assert(err == undefined); console.log("Checked error."); });); #endif And now I get those outputs: {{ blog:2023:016_homeview_async_fs_sync_result.png }} => So, it could be here that at the time I try to load the state.yml file, the IDBFS filesystem is not in sync yet! And thus, file is missing! Let's try to delay the loading of the state file until I realy need it. Okay, what we can see already with this is that the IDBFS load will indeed only be completed after quite some time since we can send our first GET requests and all before that: {{ blog:2023:017_homeview_async_fs_sync_done.png }} But if with that (only trying to load the state.yml file just when I need the credentials), still no luck: no file found 😨 That is just unbelievable... ahhh!!! Naaa, I still had some mixing on the true/false populate value, once this is sorted, FINALLY, this seems to work! 🥳🤣😬😍! My god... this was burning my head so much lol. I think I deserve a good break now 👍. Okay, back on it now. First, some minor cleaning: I just realized that for each post command I was keeping track of the JSON data provided as input in a string map, but never releasing this data [not good Manu :-)], so fixing that now in the **downloadSucceeded** and **downloadFailed** callbacks: // Also check if we had JSON data for this request: auto& dmap = get_request_data_map(); auto it2 = dmap.find(id); if (it2 != dmap.end()) { dmap.erase(it2); }/*//*/ Next step will be to investigate how to use brotli to compress our WASM file. But I think I should kep that for another session now ;-) See ya next time!