Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ jobs:
working-directory: packages/flet
run: flutter test

# Tests marked `@TestOn("browser")` are skipped by the VM run above and
# only exercise code paths that differ on web (e.g. the embedded app
# WebSocket endpoint, which reads the host document's configuration).
- name: Run web tests
shell: bash
working-directory: packages/flet
run: flutter test --platform chrome test/transport/web_socket_endpoint_web_test.dart

- name: Publish to pub.dev (Dry Run)
shell: bash
working-directory: packages/flet
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@

### Bug fixes

* Fix embedded `FletApp` web apps served from a path-prefixed URL never connecting: the embedded session derived its WebSocket path from the **host** document's endpoint configuration — which describes the host app and is shared by every app embedded on the page — so the URL's path prefix was lost before connecting (e.g. `FletApp(url="https://gateway/device1/")` behind a reverse proxy opened `wss://gateway/ws` instead of `wss://gateway/device1/ws`). Embedded apps now derive the endpoint from their own URL, matching what the io implementation already did; root apps are unchanged ([#6794](https://github.com/flet-dev/flet/issues/6794)) by @jmvillalba.
* Fix a Flet web app silently freezing in Safari after navigating away and back in the same tab. Safari restores the page from its back/forward cache with Flutter's frame scheduling broken: clicks still reached Python, the websocket reconnected, and server patches arrived and applied - but scheduled frames never rendered, so the screen stayed at its last painted state while the server kept processing events. The freeze looked intermittent because a restore can paint one synchronous catch-up frame before wedging on the very next update, so another Back/Forward appeared to briefly "fix" it. The page now reloads itself when a WebKit browser restores it from the back/forward cache (`pageshow` with `persisted`); for a hosted app the Flet session lives on the server and survives the reload, so state is preserved. The reload is gated to WebKit - every browser on iOS, Safari on macOS - because other engines restore correctly, and for a `flet build web` (Pyodide) app, whose state lives in the page, an unnecessary reload would destroy it. Applied to both the hosted web client and the `flet build` web template by @FeodorFitsner.

* Fix `flet create --template extension` generating an example app whose `pyproject.toml` cannot be parsed on Windows. The `[tool.flet.dev_packages]` and `[tool.uv.sources]` entries that point back at the extension package interpolated the host's `os.sep` into a TOML basic string, so on Windows they rendered as `"..\.."` and `"..\..\"`: the first is an invalid escape sequence, and in the second the trailing `\"` escapes the closing quote and leaves the string unterminated — the "unbalanced quotes" error reporters hit before they could build or run the generated example. Both paths are now written with forward slashes, which need no escaping in TOML and which `pathlib` and `uv` accept on Windows just as they do elsewhere ([#5507](https://github.com/flet-dev/flet/issues/5507), [#6775](https://github.com/flet-dev/flet/pulls/6775)) by @ndonkoHenri.
* Fix `flet publish`'s documented `[tool.flet.web].route_url_strategy` and `FLET_WEB_ROUTE_URL_STRATEGY` fallbacks being unreachable: the `--route-url-strategy` option's argparse default of `"path"` made the CLI value always win. The default now applies at the end of the resolution chain, as in `flet build`, by @ndonkoHenri.
* Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (`<true />`), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit `<true/>`, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri.
Expand Down
1 change: 1 addition & 0 deletions packages/flet/lib/src/flet_backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ class FletBackend extends ChangeNotifier {
address: pageUri.toString(),
args: args ?? {},
forcePyodide: forcePyodide == true,
embedded: controlId != null,
onDisconnect: _onDisconnect,
onPacket: _onPacket);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/flet/lib/src/transport/flet_backend_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ abstract class FletBackendChannel {
{required String address,
required Map<String, dynamic> args,
required bool forcePyodide,
bool embedded = false,
required FletBackendChannelOnDisconnectCallback onDisconnect,
required FletBackendChannelOnPacketCallback onPacket}) {
if (isPyodideMode() || forcePyodide) {
Expand All @@ -52,7 +53,10 @@ abstract class FletBackendChannel {
address.startsWith("https://")) {
// WebSocket
return FletWebSocketBackendChannel(
address: address, onDisconnect: onDisconnect, onPacket: onPacket);
address: address,
embedded: embedded,
onDisconnect: onDisconnect,
onPacket: onPacket);
} else if (address == "mock") {
// Mock
return FletMockBackendChannel(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ class FletWebSocketBackendChannel implements FletBackendChannel {
FletBackendChannelOnDisconnectCallback onDisconnect;
WebSocketChannel? _channel;

/// Whether this channel serves an embedded app (a `FletApp` control) rather
/// than the root app of the page.
final bool embedded;

FletWebSocketBackendChannel(
{required String address,
this.embedded = false,
required this.onDisconnect,
required this.onPacket}) {
_wsUrl = getWebSocketEndpoint(Uri.parse(address));
Expand Down Expand Up @@ -75,7 +80,18 @@ class FletWebSocketBackendChannel implements FletBackendChannel {

String getWebSocketEndpoint(Uri uri) {
final wsScheme = uri.scheme == "https" ? "wss" : "ws";
final wsPath = getWebsocketEndpointPath(uri.path);
// An embedded app must derive its WebSocket path from its own URL — the
// host document's endpoint configuration describes the host app, not the
// embedded one, and is the same for every app embedded on the page (so a
// path-prefixed embedded URL would lose its prefix and never connect).
// The root app keeps reading the host configuration: on web its URL path
// may contain the app *route* rather than the mount point, which is why
// the endpoint is configured by the server in the first place. On io both
// branches are equivalent (the io implementation already derives the
// path from the URL).
final wsPath = embedded
? getWebsocketEndpointPathFromUriPath(uri.path)
: getWebsocketEndpointPath(uri.path);
if (wsPath == "") {
throw Exception("WebSocket endpoint path cannot be empty.");
}
Expand Down
8 changes: 2 additions & 6 deletions packages/flet/lib/src/utils/platform_utils_non_web.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import 'strings.dart';
import 'uri.dart';

bool isProgressiveWebApp() {
return false;
}

String getWebsocketEndpointPath(String uriPath) {
var pagePath = uriPath.trimSymbol("/");
if (pagePath != "") {
pagePath = "$pagePath/";
}
return "${pagePath}ws";
return getWebsocketEndpointPathFromUriPath(uriPath);
}

String getFletRouteUrlStrategy() {
Expand Down
13 changes: 13 additions & 0 deletions packages/flet/lib/src/utils/uri.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ String getWebPageName(Uri uri) {
return urlPath;
}

/// Derives the WebSocket endpoint path of a Flet app from its own URL path:
/// `""` → `"ws"`, `"/sub1"` → `"sub1/ws"`.
///
/// This is the io implementation of `getWebsocketEndpointPath`, which delegates
/// here. On web it is used for embedded apps (the `FletApp` control), whose
/// endpoint cannot come from the host document's configuration: that
/// configuration describes the host app and is the same for every app embedded
/// on the page.
String getWebsocketEndpointPathFromUriPath(String uriPath) {
var pagePath = uriPath.trimSymbol("/");
return pagePath == "" ? "ws" : "$pagePath/ws";
}

Uri getAssetUri(Uri pageUri, String assetPath) {
return Uri(
scheme: pageUri.scheme,
Expand Down
91 changes: 91 additions & 0 deletions packages/flet/test/transport/web_socket_endpoint_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';

import 'package:flet/src/transport/flet_backend_channel.dart';
import 'package:flet/src/transport/flet_backend_channel_web_socket.dart';
import 'package:flutter_test/flutter_test.dart';

/// Endpoint composition for the embedded branch, plus the factory plumbing
/// that selects it. These hold on every platform.
///
/// The embedded-vs-root *difference* is only observable on web — on io both
/// branches derive the path from the URL — so the regression test for the fix
/// itself lives in `web_socket_endpoint_web_test.dart`, which runs under
/// `flutter test --platform chrome`.
void main() {
String endpoint(String url, {required bool embedded}) =>
FletWebSocketBackendChannel(
address: url,
embedded: embedded,
onDisconnect: () {},
onPacket: (_) {})
.getWebSocketEndpoint(Uri.parse(url));

group("getWebSocketEndpoint (embedded)", () {
// An embedded app derives its WebSocket path from its own URL, so a
// path-prefixed URL (e.g. behind a reverse proxy on a gateway host)
// keeps its prefix instead of inheriting the host document's endpoint.
test("path-prefixed URL keeps its prefix", () {
expect(endpoint("https://gateway/device1/", embedded: true),
"wss://gateway/device1/ws");
});

test("root URL on a dedicated port resolves to /ws", () {
expect(
endpoint("http://host:9001/", embedded: true), "ws://host:9001/ws");
});

test("http maps to ws, https maps to wss", () {
expect(endpoint("http://gateway/device1/", embedded: true),
startsWith("ws://"));
expect(endpoint("https://gateway/device1/", embedded: true),
startsWith("wss://"));
});

test("URL without trailing slash and nested paths keep their prefix", () {
expect(endpoint("https://gateway/device1", embedded: true),
"wss://gateway/device1/ws");
expect(endpoint("https://gateway/a/b/", embedded: true),
"wss://gateway/a/b/ws");
});
});

group("getWebSocketEndpoint (root, io)", () {
// On io the platform implementation derives the path from the URL too, so
// desktop and mobile are unaffected by the embedded flag either way. This
// is a VM-only claim: on web the root branch reads the host document's
// configuration instead (see web_socket_endpoint_web_test.dart).
test("root branch derives from the URL path", () {
expect(endpoint("https://gateway/device1/", embedded: false),
"wss://gateway/device1/ws");
expect(
endpoint("http://host:9001/", embedded: false), "ws://host:9001/ws");
});
},
skip: kIsWeb
? "io-only: on web the root branch reads the host config"
: null);

group("FletBackendChannel factory", () {
test("forwards the embedded flag to the WebSocket channel", () {
final ch = FletBackendChannel(
address: "https://gateway/device1/",
args: {},
forcePyodide: false,
embedded: true,
onDisconnect: () {},
onPacket: (_) {});
expect(ch, isA<FletWebSocketBackendChannel>());
expect((ch as FletWebSocketBackendChannel).embedded, true);
});

test("defaults to non-embedded", () {
final ch = FletBackendChannel(
address: "https://gateway/",
args: {},
forcePyodide: false,
onDisconnect: () {},
onPacket: (_) {});
expect((ch as FletWebSocketBackendChannel).embedded, false);
});
});
}
41 changes: 41 additions & 0 deletions packages/flet/test/transport/web_socket_endpoint_web_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@TestOn("browser")
library;

import 'package:flet/src/transport/flet_backend_channel_web_socket.dart';
import 'package:flutter_test/flutter_test.dart';

/// Regression test for the embedded-app WebSocket endpoint on web.
///
/// This is the only platform where the embedded and root branches differ: the
/// root branch reads `window.flet.webSocketEndpoint`, which describes the host
/// document and is shared by every app embedded on the page, while an embedded
/// app must derive its path from its own URL. On io both branches derive from
/// the URL, so a VM test cannot tell them apart.
///
/// `window.flet` is undefined under the test harness, so the root branch falls
/// back to the bare `"ws"` endpoint — which is exactly the value a
/// path-prefixed embedded app used to get, and never connected on.
///
/// Run with: `flutter test --platform chrome`
void main() {
String endpoint(String url, {required bool embedded}) =>
FletWebSocketBackendChannel(
address: url,
embedded: embedded,
onDisconnect: () {},
onPacket: (_) {})
.getWebSocketEndpoint(Uri.parse(url));

test("embedded app derives its endpoint from its own URL", () {
expect(endpoint("https://gateway/device1/", embedded: true),
"wss://gateway/device1/ws");
expect(endpoint("https://gateway/a/b/", embedded: true),
"wss://gateway/a/b/ws");
});

test("root app reads the host document's endpoint, ignoring the URL path",
() {
expect(endpoint("https://gateway/device1/", embedded: false),
"wss://gateway/ws");
});
}
9 changes: 9 additions & 0 deletions packages/flet/test/utils/uri_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,13 @@ void main() {
expect(getWebPageName(Uri.parse('http://localhost:8550/')), "");
expect(getWebPageName(Uri.parse('http://localhost:8550/#/')), "");
});

test("getWebsocketEndpointPathFromUriPath derives path from URL path", () {
expect(getWebsocketEndpointPathFromUriPath(""), "ws");
expect(getWebsocketEndpointPathFromUriPath("/"), "ws");
expect(getWebsocketEndpointPathFromUriPath("/sub1"), "sub1/ws");
expect(getWebsocketEndpointPathFromUriPath("/sub1/"), "sub1/ws");
expect(getWebsocketEndpointPathFromUriPath("sub1"), "sub1/ws");
expect(getWebsocketEndpointPathFromUriPath("/a/b/"), "a/b/ws");
});
}
Loading