diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index ab13299d2f..6ee682ce93 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -103,11 +103,11 @@ jobs: - name: Build Alpha 🔧 if: needs.extract-version.outputs.isAlpha == 'true' - run: flutter build web --release --dart-define=flavor=alpha + run: flutter build web --release --dart-define=flavor=alpha --wasm - name: Build Prod 🔧 if: needs.extract-version.outputs.isAlpha == 'false' - run: flutter build web --release --dart-define=flavor=prod + run: flutter build web --release --dart-define=flavor=prod --wasm - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.vscode/launch.json b/.vscode/launch.json index c00853d44e..50f3e56290 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,29 +5,63 @@ "version": "0.2.0", "configurations": [ { - "name": "Titan dev", + "name": "Titan dev (JS)", "request": "launch", "type": "dart", "args": ["--flavor", "dev", "--dart-define", "flavor=dev"] }, { - "name": "Titan dev (profile mode)", + "name": "Titan dev (JS, profile mode)", "request": "launch", "type": "dart", "flutterMode": "profile", "args": ["--flavor", "dev", "--dart-define", "flavor=dev"] }, { - "name": "Titan alpha", + "name": "Titan alpha (JS)", "request": "launch", "type": "dart", "args": ["--flavor", "alpha", "--dart-define", "flavor=alpha"] }, { - "name": "Titan prod", + "name": "Titan dev (WASM)", "request": "launch", "type": "dart", - "args": ["--flavor", "prod", "--dart-define", "flavor=prod"] + "args": [ + "--flavor", + "dev", + "--dart-define", + "flavor=dev", + "--wasm", + "--no-cross-origin-isolation" + ] + }, + { + "name": "Titan dev (WASM, profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile", + "args": [ + "--flavor", + "dev", + "--dart-define", + "flavor=dev", + "--wasm", + "--no-cross-origin-isolation" + ] + }, + { + "name": "Titan alpha (WASM)", + "request": "launch", + "type": "dart", + "args": [ + "--flavor", + "alpha", + "--dart-define", + "flavor=alpha", + "--wasm", + "--no-cross-origin-isolation" + ] } ] } diff --git a/Dockerfile b/Dockerfile index cbb7592e15..eeec49a477 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.28.0-alpine3.21-slim +FROM nginx:1.28.2-alpine3.23-slim COPY nginx.conf /etc/nginx/nginx.conf COPY ./build/web/ /app/html RUN find /app/html/ -type f -size +512c -regex '.*\.\(html\|css\|js\|json\|svg\|ttf\|otf\|woff2\|wasm\|mjs\|symbols\|yaml\|env\)' -exec gzip -k9 {} + \ No newline at end of file diff --git a/README.md b/README.md index 438fc3ea22..2c0c1af8b8 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ flutter run --flavor alpha More generally you can use: ```bash -flutter run --flavor [ -d ] +flutter run --flavor [ -d ] [ --wasm --no-cross-origin-isolation ] ``` - Where the flavor can be any of `dev`, `alpha`, or `prod` (whose policy is to only accept the prod client). @@ -196,6 +196,8 @@ flutter run --flavor alpha -d chrome flutter run --flavor dev -d web-server ``` +- The optional duo `--wasm --no-cross-origin-isolation` runs a version compiled to WebAssembly instead of Javascript. + ### Check the app is running @@ -280,9 +282,11 @@ flutter build {target} --flavor={flavor} Currently flavor are not supported for Flutter for web, you should use: ``` -flutter build web --dart-define=flavor={flavor} +flutter build web --dart-define=flavor={flavor} [--wasm] ``` +The optional `--wasm` flag enables Titan to be also compiled to WebAssembly (besides the Javascript compilation). + ### Notifications setup Notifications are handled using the Firebase Cloud Messaging API. On mobile platforms, a valid notification configuration is required to debug Titan. Notifications are disabled on web builds. diff --git a/lib/auth/providers/openid_provider.dart b/lib/auth/providers/openid_provider.dart index 1e7c40bc7e..709c0fc4f0 100644 --- a/lib/auth/providers/openid_provider.dart +++ b/lib/auth/providers/openid_provider.dart @@ -13,7 +13,7 @@ import 'package:titan/tools/cache/cache_manager.dart'; import 'package:titan/tools/functions.dart'; import 'package:titan/tools/repository/repository.dart'; import 'dart:convert'; -import 'package:universal_html/html.dart' as html; +import 'package:titan/tools/web-window-callback/web_window_with_callback.dart'; final authTokenProvider = StateNotifierProvider>>( @@ -140,7 +140,6 @@ class OpenIdTokenProvider } Future getTokenFromRequest() async { - html.WindowBase? popupWin; final codeVerifier = generateRandomString(128); final authUrl = @@ -149,72 +148,46 @@ class OpenIdTokenProvider state = const AsyncValue.loading(); try { if (kIsWeb) { - popupWin = html.window.open( + webWindowWithCallback( authUrl, "Hyperion", - "width=800, height=900, scrollbars=yes", - ); - - final completer = Completer(); - void checkWindowClosed() { - if (popupWin != null && popupWin!.closed == true) { - completer.complete(); - } else { - Future.delayed( - const Duration(milliseconds: 100), - checkWindowClosed, + completerFutureCallback: () { + state.maybeWhen( + loading: () { + state = AsyncValue.data({tokenKey: "", refreshTokenKey: ""}); + }, + orElse: () {}, ); - } - } - - checkWindowClosed(); - completer.future.then((_) { - state.maybeWhen( - loading: () { - state = AsyncValue.data({tokenKey: "", refreshTokenKey: ""}); - }, - orElse: () {}, - ); - }); - - void login(String data) async { - final receivedUri = Uri.parse(data); - final token = receivedUri.queryParameters["code"]; - if (popupWin != null) { - popupWin!.close(); - popupWin = null; - } - try { - if (token != null && token.isNotEmpty) { - final resp = await openIdRepository.getToken( - token, - clientId, - redirectURL.toString(), - codeVerifier, - "authorization_code", - ); - final accessToken = resp[tokenKey]!; - final refreshToken = resp[refreshTokenKey]!; - await _secureStorage.write(key: tokenName, value: refreshToken); - state = AsyncValue.data({ - tokenKey: accessToken, - refreshTokenKey: refreshToken, - }); - } else { - throw Exception('Wrong credentials'); + }, + loginCallback: (String data) async { + final receivedUri = Uri.parse(data); + final token = receivedUri.queryParameters["code"]; + try { + if (token != null && token.isNotEmpty) { + final resp = await openIdRepository.getToken( + token, + clientId, + redirectURL.toString(), + codeVerifier, + "authorization_code", + ); + final accessToken = resp[tokenKey]!; + final refreshToken = resp[refreshTokenKey]!; + await _secureStorage.write(key: tokenName, value: refreshToken); + state = AsyncValue.data({ + tokenKey: accessToken, + refreshTokenKey: refreshToken, + }); + } else { + throw Exception('Wrong credentials'); + } + } on TimeoutException catch (_) { + throw Exception('No response from server'); + } catch (e) { + rethrow; } - } on TimeoutException catch (_) { - throw Exception('No response from server'); - } catch (e) { - rethrow; - } - } - - html.window.onMessage.listen((event) { - if (event.data.toString().contains('code=')) { - login(event.data); - } - }); + }, + ); } else { AuthorizationTokenResponse resp = await appAuth .authorizeAndExchangeCode( diff --git a/lib/mypayment/ui/pages/fund_page/confirm_button.dart b/lib/mypayment/ui/pages/fund_page/confirm_button.dart index 946b7fb5a7..2cf6bc6575 100644 --- a/lib/mypayment/ui/pages/fund_page/confirm_button.dart +++ b/lib/mypayment/ui/pages/fund_page/confirm_button.dart @@ -12,8 +12,8 @@ import 'package:titan/mypayment/providers/my_wallet_provider.dart'; import 'package:titan/mypayment/providers/tos_provider.dart'; import 'package:titan/tools/functions.dart'; import 'package:titan/tools/ui/builders/waiting_button.dart'; -import 'package:universal_html/html.dart' as html; import 'package:url_launcher/url_launcher.dart'; +import 'package:titan/tools/web-window-callback/web_window_with_callback.dart'; class ConfirmFundButton extends ConsumerWidget { const ConfirmFundButton({super.key}); @@ -60,50 +60,26 @@ class ConfirmFundButton extends ConsumerWidget { } void helloAssoCallback(String fundingUrl) async { - html.WindowBase? popupWin = - html.window.open( - fundingUrl, - "HelloAsso", - "width=800, height=900, scrollbars=yes", - ) - as html.WindowBase?; - - if (popupWin == null) { - displayToastWithContext(TypeMsg.error, "Veuillez autoriser les popups"); - return; - } - - final completer = Completer(); - void checkWindowClosed() { - if (popupWin.closed == true) { - completer.complete(); - } else { - Future.delayed(const Duration(milliseconds: 100), checkWindowClosed); - } - } - - checkWindowClosed(); - completer.future.then((_) {}); - - void login(String data) async { - final receivedUri = Uri.parse(data); - final code = receivedUri.queryParameters["code"]; - if (code == "succeeded") { - displayToastWithContext(TypeMsg.msg, "Paiement effectué avec succès"); - myWalletNotifier.getMyWallet(); - myHistoryNotifier.getHistory(); - } else { - displayToastWithContext(TypeMsg.error, "Paiement annulé"); - } - popupWin.close(); - Navigator.pop(context, code); - } - - html.window.onMessage.listen((event) { - if (event.data.toString().contains('code=')) { - login(event.data); - } - }); + webWindowWithCallback( + fundingUrl, + "HelloAsso", + completerFutureCallback: () {}, + loginCallback: (String data) async { + final receivedUri = Uri.parse(data); + final code = receivedUri.queryParameters["code"]; + if (code == "succeeded") { + displayToastWithContext( + TypeMsg.msg, + "Paiement effectué avec succès", + ); + myWalletNotifier.getMyWallet(); + myHistoryNotifier.getHistory(); + } else { + displayToastWithContext(TypeMsg.error, "Paiement annulé"); + } + Navigator.pop(context, code); + }, + ); } return WaitingButton( diff --git a/lib/tools/web-window-callback/else.dart b/lib/tools/web-window-callback/else.dart new file mode 100644 index 0000000000..5ec73b31e3 --- /dev/null +++ b/lib/tools/web-window-callback/else.dart @@ -0,0 +1,10 @@ +import 'dart:async'; + +void webWindowWithCallback( + String windowUrl, + String windowName, { + required void Function() completerFutureCallback, + required Future Function(String) loginCallback, +}) async => throw UnsupportedError( + "`webWindowWithCallback()` is not implemented for the current platform", +); diff --git a/lib/tools/web-window-callback/html.dart b/lib/tools/web-window-callback/html.dart new file mode 100644 index 0000000000..d45947283a --- /dev/null +++ b/lib/tools/web-window-callback/html.dart @@ -0,0 +1,35 @@ +import 'dart:async'; +import 'package:universal_html/universal_html.dart'; + +void webWindowWithCallback( + String windowUrl, + String windowName, { + required void Function() completerFutureCallback, + required Future Function(String) loginCallback, +}) async { + WindowBase popupWin = window.open( + windowUrl, + windowName, + "width=800, height=900, scrollbars=yes", + ); + + final completer = Completer(); + void checkWindowClosed() { + if (popupWin.closed == true) { + completer.complete(); + } else { + Future.delayed(const Duration(milliseconds: 100), checkWindowClosed); + } + } + + checkWindowClosed(); + completer.future.then((_) => completerFutureCallback()); + + window.onMessage.listen((event) { + final data = event.data.toString(); + if (data.contains('code=')) { + loginCallback(data); + popupWin.close(); + } + }); +} diff --git a/lib/tools/web-window-callback/js_interop.dart b/lib/tools/web-window-callback/js_interop.dart new file mode 100644 index 0000000000..3c5b0c2679 --- /dev/null +++ b/lib/tools/web-window-callback/js_interop.dart @@ -0,0 +1,38 @@ +import 'dart:async'; +import 'package:web/web.dart'; +import 'dart:js_interop'; + +void webWindowWithCallback( + String windowUrl, + String windowName, { + required void Function() completerFutureCallback, + required Future Function(String) loginCallback, +}) async { + Window? popupWin = window.open( + windowUrl, + windowName, + "width=800, height=900, scrollbars=yes", + ); + + final completer = Completer(); + void checkWindowClosed() { + if (popupWin?.closed == true) { + completer.complete(); + } else { + Future.delayed(const Duration(milliseconds: 100), checkWindowClosed); + } + } + + checkWindowClosed(); + completer.future.then((_) => completerFutureCallback()); + + window.onMessage.listen((event) { + try { + final data = (event.data as JSString).toDart; + if (data.contains('code=')) { + loginCallback(data); + popupWin?.close(); + } + } catch (_) {} + }); +} diff --git a/lib/tools/web-window-callback/web_window_with_callback.dart b/lib/tools/web-window-callback/web_window_with_callback.dart new file mode 100644 index 0000000000..a1b6083107 --- /dev/null +++ b/lib/tools/web-window-callback/web_window_with_callback.dart @@ -0,0 +1,3 @@ +export 'else.dart' + if (dart.library.js_interop) 'js_interop.dart' + if (dart.library.html) 'html.dart'; diff --git a/nginx.conf b/nginx.conf index a3330e786a..5fbcacbf38 100644 --- a/nginx.conf +++ b/nginx.conf @@ -13,6 +13,12 @@ http { server_tokens off; keepalive_timeout 65; + include mime.types; + types { + text/javascript mjs; + } + default_type application/octet-stream; + gzip on; gzip_disable "msie6"; gzip_vary on; @@ -50,18 +56,24 @@ http { # Path to the root of your installation root /app/html; - + # Redirect on 404 error error_page 404 @home; - + location @home { return 301 /; } - + location / { + # Disable caching to ensure the client gets the latest version add_header Cache-Control 'no-store'; add_header Cache-Control 'no-cache'; add_header Cache-Control 'must-revalidate'; + + # WebAssembly + add_header Cross-Origin-Embedder-Policy 'credentialless'; + add_header Cross-Origin-Opener-Policy 'unsafe-none'; + expires 0; try_files $uri $uri/ /index.html; } diff --git a/pubspec.lock b/pubspec.lock index 06f217b15d..4b22c6b951 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1438,18 +1438,18 @@ packages: dependency: "direct main" description: name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.0" universal_io: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" universal_platform: dependency: transitive description: @@ -1579,7 +1579,7 @@ packages: source: hosted version: "1.1.1" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/pubspec.yaml b/pubspec.yaml index 30f5b39bc0..e5fbaaa00e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,9 +70,10 @@ dependencies: timeago: ^3.7.0 timezone: ^0.10.0 tuple: ^2.0.0 - universal_html: ^2.0.8 + universal_html: ^2.3.0 url_launcher: ^6.2.5 uuid: ^4.5.1 + web: ^1.1.1 webview_flutter: ^4.10.0 yaml: ^3.1.3 diff --git a/web_dev_config.yaml b/web_dev_config.yaml index 0e85c0fc63..bfc3dc82c5 100644 --- a/web_dev_config.yaml +++ b/web_dev_config.yaml @@ -1,5 +1,11 @@ server: port: 3000 headers: + # Caching - name: Cache-Control value: no-store, no-cache, must-revalidate + # WebAssembly + - name: Cross-Origin-Embedder-Policy + value: credentialless + - name: Cross-Origin-Opener-Policy + value: unsafe-none