From 78d6f95df1e904939cea569235ed0020b4cdc8bf Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 28 Aug 2026 13:08:43 -0700 Subject: [PATCH 01/10] feat: client actions infrastructure and OpenUrl Browsers permit gesture-gated APIs - opening a file picker, writing to the clipboard, navigator.share, window.open - only while user activation is live, and activation does not survive an async gap. Flet's services round-trip through Python, so those calls run in a websocket message handler with no activation: silently ignored on iOS Safari, allowed by the more lenient Chrome and Firefox. Button.url already avoided this by being declarative and running inside onPressed. This generalizes that into a client action: a control's new 'action' property names a service method the client invokes itself, synchronously, from the gesture callback. runClientActions() resolves the target service via controlsIndex and calls invokeMethod() without awaiting. Control.hasInvokeMethodListeners guards the case where the service is not mounted yet, since invokeMethod() would otherwise await a listener and consume the gesture. url behavior is unchanged - it moves into the shared runControlActions() helper alongside actions. --- CHANGELOG.md | 2 + packages/flet/CHANGELOG.md | 4 + packages/flet/lib/src/controls/button.dart | 7 +- packages/flet/lib/src/controls/container.dart | 22 ++- .../lib/src/controls/cupertino_button.dart | 7 +- .../lib/src/controls/cupertino_list_tile.dart | 11 +- .../src/controls/floating_action_button.dart | 7 +- .../flet/lib/src/controls/icon_button.dart | 7 +- packages/flet/lib/src/controls/list_tile.dart | 11 +- packages/flet/lib/src/controls/text.dart | 1 + packages/flet/lib/src/models/control.dart | 9 ++ .../flet/lib/src/utils/client_actions.dart | 76 ++++++++++ packages/flet/lib/src/utils/text.dart | 34 +++-- .../url_launcher/open_url_action/main.py | 43 ++++++ .../open_url_action/pyproject.toml | 26 ++++ sdk/python/packages/flet/src/flet/__init__.py | 8 + .../flet/src/flet/controls/client_action.py | 142 ++++++++++++++++++ .../flet/src/flet/controls/core/text_span.py | 12 ++ .../controls/cupertino/cupertino_button.py | 12 ++ .../controls/cupertino/cupertino_list_tile.py | 12 ++ .../flet/src/flet/controls/material/button.py | 12 ++ .../src/flet/controls/material/container.py | 12 ++ .../material/floating_action_button.py | 12 ++ .../src/flet/controls/material/icon_button.py | 12 ++ .../src/flet/controls/material/list_tile.py | 12 ++ .../flet/controls/material/outlined_button.py | 12 ++ .../src/flet/controls/material/text_button.py | 12 ++ website/docs/types/clientaction.md | 7 + website/docs/types/openurl.md | 7 + website/sidebars.yml | 3 + 30 files changed, 495 insertions(+), 59 deletions(-) create mode 100644 packages/flet/lib/src/utils/client_actions.dart create mode 100644 sdk/python/examples/services/url_launcher/open_url_action/main.py create mode 100644 sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml create mode 100644 sdk/python/packages/flet/src/flet/controls/client_action.py create mode 100644 website/docs/types/clientaction.md create mode 100644 website/docs/types/openurl.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ca94e07f82..ca17bcc4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### New features +* **Client actions: an `action` property that performs gesture-gated work without a round trip to Python.** Browsers only let a page open a file picker, write to the clipboard, show a share sheet or open a new tab while they are still handling the user's click or key press. Sending that click to your Python code and acting on the reply takes longer than the permission lasts, so on iOS Safari those operations were silently ignored while Android and desktop browsers let them through - which made a browser rule look like a Flet bug. `Button`, `Container`, `IconButton`, `ListTile`, `CupertinoButton`, `CupertinoListTile`, `FloatingActionButton`, `OutlinedButton`, `TextButton` and `TextSpan` now accept `action` - a single `ClientAction` or a list of them - which the client performs inside the original gesture, before your `on_click` handler is even notified: `ft.Button("Open", action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK))`. Because an action runs before your code sees the click, its arguments have to be known in advance; to act on a value computed at click time, set it on the control ahead of the click. `url` is unchanged and keeps working exactly as before by @FeodorFitsner. + * **macOS code signing, notarization, and Mac App Store builds in `flet build macos`.** Select a distribution lane with `--macos-distribution` (or `[tool.flet.macos.signing].distribution`): `developer-id` signs every bundled binary with your Developer ID certificate — hardened runtime, entitlements, secure timestamp — then notarizes and staples the app for direct distribution, while `app-store` produces a sandboxed app with your provisioning profile embedded, packaged into an installer-signed `.pkg` ready for App Store Connect and TestFlight. Signing identities are auto-discovered from the keychain when not explicitly configured (via CLI options, `pyproject.toml` — including per-lane `[tool.flet.macos.signing.]` subtables — or environment variables), and the whole configuration is validated before the build starts, so a typo'd identity, expired certificate, or missing store prerequisite fails in seconds instead of after the full build. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing), [Notarization](https://flet.dev/docs/publish/macos#notarization), and [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#4543](https://github.com/flet-dev/flet/issues/4543), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. ### Improvements diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index 7b57fbbb5a..31af5d808c 100644 --- a/packages/flet/CHANGELOG.md +++ b/packages/flet/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.0 + +* Add `runClientActions()` and `runControlActions()` in `utils/client_actions.dart`, which perform a control's `url` and `action` properties on the client, synchronously, from inside the gesture callback that triggered them. `runClientActions()` resolves each action's target service through `FletBackend.controlsIndex` and calls `Control.invokeMethod()` on it without awaiting, because browsers grant gesture-gated APIs - opening a file picker, writing to the clipboard, `navigator.share`, `window.open` - only while user activation is live, and activation does not survive an async gap. The new `Control.hasInvokeMethodListeners` getter guards that path: `Control.invokeMethod()` waits for a listener when the target service is not mounted yet, and awaiting that wait would silently consume the gesture, so an unresolvable action is skipped and logged instead. A new `Control.hasControlActions` extension getter reports whether a control has anything to run, for controls that only install a tap handler when something is wired to them. `openWebBrowser()` is no longer called directly by `Button`, `Container`, `IconButton`, `ListTile`, `CupertinoListTile`, `CupertinoButton` and `FloatingActionButton`, which now dispatch through `runControlActions()`; `parseTextSpans()` and `parseInlineSpan()` take an optional trailing `BuildContext` so a `TextSpan` can carry actions too. + ## 0.86.7 * `FletJS.canvasKitBaseUrl` is now `String?`. `flutter_bootstrap.js` applies `flet.canvasKitBaseUrl` and `flet.fontFallbackBaseUrl` whenever they are set rather than only when `flet.noCdn` is true, and both default to `null` in CDN mode — so a host serving its own copy of the runtime can point them anywhere without also claiming a no-CDN build. The getter has no readers in this package; the annotation now matches the value it can carry. diff --git a/packages/flet/lib/src/controls/button.dart b/packages/flet/lib/src/controls/button.dart index 3d2b276ad9..279e84e93a 100644 --- a/packages/flet/lib/src/controls/button.dart +++ b/packages/flet/lib/src/controls/button.dart @@ -4,7 +4,7 @@ import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/buttons.dart'; import '../utils/colors.dart'; -import '../utils/launch_url.dart'; +import '../utils/client_actions.dart'; import '../utils/misc.dart'; import '../utils/numbers.dart'; import '../widgets/error.dart'; @@ -63,7 +63,6 @@ class _ButtonControlState extends State with FletStoreMixin { bool isTextButton = widget.control.type == "TextButton"; bool isOutlinedButton = widget.control.type == "OutlinedButton"; - var url = widget.control.getUrl("url"); var iconColor = widget.control.getColor("icon_color", context); var clipBehavior = widget.control.getClipBehavior("clip_behavior", Clip.none)!; @@ -74,9 +73,7 @@ class _ButtonControlState extends State with FletStoreMixin { Function()? onPressed = !widget.control.disabled ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, widget.control); widget.control.triggerEvent("click"); } : null; diff --git a/packages/flet/lib/src/controls/container.dart b/packages/flet/lib/src/controls/container.dart index 3c1a06670e..e3f6874c8e 100644 --- a/packages/flet/lib/src/controls/container.dart +++ b/packages/flet/lib/src/controls/container.dart @@ -6,12 +6,12 @@ import '../utils/alignment.dart'; import '../utils/animations.dart'; import '../utils/borders.dart'; import '../utils/box.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/events.dart'; import '../utils/gradient.dart'; import '../utils/images.dart'; -import '../utils/launch_url.dart'; import '../utils/misc.dart'; import '../utils/numbers.dart'; import '../widgets/flet_store_mixin.dart'; @@ -34,7 +34,7 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { var ink = control.getBool("ink", false)!; var onClick = control.hasEventHandler("click"); var onTapDown = control.hasEventHandler("tap_down"); - var url = control.getUrl("url"); + var hasActions = control.hasControlActions; var onLongPress = control.hasEventHandler("long_press"); var onHover = control.hasEventHandler("hover"); var ignoreInteractions = control.getBool("ignore_interactions", false)!; @@ -68,7 +68,7 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { var onAnimationEnd = control.hasEventHandler("animation_end") ? () => control.triggerEvent("animation_end", "container") : null; - if ((onClick || url != null || onLongPress || onHover || onTapDown) && + if ((onClick || hasActions || onLongPress || onHover || onTapDown) && ink && !control.disabled) { // `padding` and `alignment` are applied inside the `InkWell` only, so that @@ -96,11 +96,9 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { // Dummy callback to enable widget // see https://github.com/flutter/flutter/issues/50116#issuecomment-582047374 // and https://github.com/flutter/flutter/blob/eed80afe2c641fb14b82a22279d2d78c19661787/packages/flutter/lib/src/material/ink_well.dart#L1125-L1129 - onTap: onClick || url != null || onTapDown + onTap: onClick || hasActions || onTapDown ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onClick) { control.triggerEvent("click"); } @@ -168,10 +166,10 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { onEnd: onAnimationEnd, child: content); - if ((onClick || onLongPress || onHover || onTapDown || url != null) && + if ((onClick || onLongPress || onHover || onTapDown || hasActions) && !control.disabled) { container = MouseRegion( - cursor: onClick || onTapDown || url != null + cursor: onClick || onTapDown || hasActions ? SystemMouseCursors.click : MouseCursor.defer, onEnter: onHover @@ -185,11 +183,9 @@ class ContainerControl extends StatelessWidget with FletStoreMixin { } : null, child: GestureDetector( - onTap: onClick || url != null + onTap: onClick || hasActions ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onClick) { control.triggerEvent("click"); } diff --git a/packages/flet/lib/src/controls/cupertino_button.dart b/packages/flet/lib/src/controls/cupertino_button.dart index 11fe836cd3..9d7189328e 100644 --- a/packages/flet/lib/src/controls/cupertino_button.dart +++ b/packages/flet/lib/src/controls/cupertino_button.dart @@ -5,10 +5,10 @@ import '../models/control.dart'; import '../utils/alignment.dart'; import '../utils/borders.dart'; import '../utils/buttons.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/geometry.dart'; -import '../utils/launch_url.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; import 'base_controls.dart'; @@ -136,12 +136,9 @@ class _CupertinoButtonControlState extends State { .copyWith(color: color), child: child); } - var url = widget.control.getUrl("url"); Function()? onPressed = !widget.control.disabled ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, widget.control); widget.control.triggerEvent("click"); } : null; diff --git a/packages/flet/lib/src/controls/cupertino_list_tile.dart b/packages/flet/lib/src/controls/cupertino_list_tile.dart index ebab294b70..eff083e544 100644 --- a/packages/flet/lib/src/controls/cupertino_list_tile.dart +++ b/packages/flet/lib/src/controls/cupertino_list_tile.dart @@ -2,9 +2,9 @@ import 'package:flutter/cupertino.dart'; import '../extensions/control.dart'; import '../models/control.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; -import '../utils/launch_url.dart'; import '../utils/numbers.dart'; import '../widgets/error.dart'; import 'base_controls.dart'; @@ -38,17 +38,14 @@ class CupertinoListTileControl extends StatelessWidget { control.getDouble("leading_to_title", notched ? 12.0 : 16.0)!; var onclick = control.hasEventHandler("click"); var toggleInputs = control.getBool("toggle_inputs", false)!; - var url = control.getUrl("url"); - Function()? onPressed = - (onclick || toggleInputs || url != null) && !control.disabled + (onclick || toggleInputs || control.hasControlActions) && + !control.disabled ? () { if (toggleInputs) { _clickNotifier.onClick(); } - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onclick) { control.triggerEvent("click"); } diff --git a/packages/flet/lib/src/controls/floating_action_button.dart b/packages/flet/lib/src/controls/floating_action_button.dart index 9c56bc6f46..72d04c615f 100644 --- a/packages/flet/lib/src/controls/floating_action_button.dart +++ b/packages/flet/lib/src/controls/floating_action_button.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/borders.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; -import '../utils/launch_url.dart'; import '../utils/misc.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; @@ -25,7 +25,6 @@ class FloatingActionButtonControl extends StatelessWidget { var content = control.buildTextOrWidget("content"); var icon = control.buildIconOrWidget("icon"); - var url = control.getUrl("url"); var disabledElevation = control.getDouble("disabled_elevation"); var elevation = control.getDouble("elevation"); var hoverElevation = control.getDouble("hover_elevation"); @@ -46,9 +45,7 @@ class FloatingActionButtonControl extends StatelessWidget { Function()? onPressed = control.disabled ? null : () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); control.triggerEvent("click"); }; diff --git a/packages/flet/lib/src/controls/icon_button.dart b/packages/flet/lib/src/controls/icon_button.dart index ac7e6b1be2..6c9af211f6 100644 --- a/packages/flet/lib/src/controls/icon_button.dart +++ b/packages/flet/lib/src/controls/icon_button.dart @@ -6,10 +6,10 @@ import '../models/control.dart'; import '../utils/alignment.dart'; import '../utils/box.dart'; import '../utils/buttons.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; import '../utils/icons.dart'; -import '../utils/launch_url.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; import '../widgets/error.dart'; @@ -96,13 +96,10 @@ class _IconButtonControlState extends State var enableFeedback = widget.control.getBool("enable_feedback", true)!; var selected = widget.control.getBool("selected"); var mouseCursor = widget.control.getMouseCursor("mouse_cursor"); - var url = widget.control.getUrl("url"); Function()? onPressed = !widget.control.disabled ? () { - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, widget.control); widget.control.triggerEvent("click"); } : null; diff --git a/packages/flet/lib/src/controls/list_tile.dart b/packages/flet/lib/src/controls/list_tile.dart index 6aa011e766..359fc493ce 100644 --- a/packages/flet/lib/src/controls/list_tile.dart +++ b/packages/flet/lib/src/controls/list_tile.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/borders.dart'; +import '../utils/client_actions.dart'; import '../utils/colors.dart'; import '../utils/edge_insets.dart'; -import '../utils/launch_url.dart'; import '../utils/misc.dart'; import '../utils/mouse.dart'; import '../utils/numbers.dart'; @@ -47,17 +47,14 @@ class ListTileControl extends StatelessWidget with FletStoreMixin { var trailing = control.buildIconOrWidget("trailing"); var onClick = control.hasEventHandler("click"); var toggleInputs = control.getBool("toggle_inputs", false)!; - var url = control.getUrl("url"); - Function()? onPressed = - (onClick || toggleInputs || url != null) && !control.disabled + (onClick || toggleInputs || control.hasControlActions) && + !control.disabled ? () { if (toggleInputs) { _clickNotifier.onClick(); } - if (url != null) { - openWebBrowser(url); - } + runControlActions(context, control); if (onClick) { control.triggerEvent("click"); } diff --git a/packages/flet/lib/src/controls/text.dart b/packages/flet/lib/src/controls/text.dart index d44aaa9e50..3f5c536208 100644 --- a/packages/flet/lib/src/controls/text.dart +++ b/packages/flet/lib/src/controls/text.dart @@ -32,6 +32,7 @@ class TextControl extends StatelessWidget { (Control control, String eventName, [dynamic eventData]) { control.triggerEvent(eventName, eventData); }, + context, ); var semanticsLabel = control.getString("semantics_label"); var noWrap = control.getBool("no_wrap", false)!; diff --git a/packages/flet/lib/src/models/control.dart b/packages/flet/lib/src/models/control.dart index f7a6977a40..1d6f0be53e 100644 --- a/packages/flet/lib/src/models/control.dart +++ b/packages/flet/lib/src/models/control.dart @@ -481,6 +481,15 @@ class Control extends ChangeNotifier { _invokeMethodListeners.remove(listener); } + /// Whether a listener is already registered, i.e. whether [invokeMethod] + /// will dispatch synchronously instead of awaiting one. + /// + /// Callers that must not lose the browser's user activation - see + /// `runClientActions` in `utils/client_actions.dart` - check this first, + /// because the wait in [invokeMethod] is an async gap that silently discards + /// the gesture. + bool get hasInvokeMethodListeners => _invokeMethodListeners.isNotEmpty; + Future invokeMethod( String name, dynamic args, Duration timeout) async { debugPrint("$type($id).$name($args)"); diff --git a/packages/flet/lib/src/utils/client_actions.dart b/packages/flet/lib/src/utils/client_actions.dart new file mode 100644 index 0000000000..99c09533dd --- /dev/null +++ b/packages/flet/lib/src/utils/client_actions.dart @@ -0,0 +1,76 @@ +import 'package:flutter/widgets.dart'; + +import '../flet_backend.dart'; +import '../models/control.dart'; +import 'launch_url.dart'; + +/// Client actions can run for as long as the user keeps a native dialog open, +/// so the invoke-method timeout has to be generous rather than interactive. +const _kClientActionTimeout = Duration(hours: 1); + +/// Runs the client actions declared on a control, by invoking the target +/// service's method directly on this client. +/// +/// MUST be called synchronously from a gesture callback, and its result MUST +/// NOT be awaited. Browsers - WebKit strictly, others more leniently - permit +/// gesture-gated APIs such as opening a file picker, writing to the clipboard, +/// `navigator.share` and `window.open` only while user activation is live, and +/// activation does not survive an async gap. Awaiting anything before the +/// service method is entered silently breaks every one of them on iOS Safari +/// while leaving Android and desktop working. See flet-dev/flet#3710. +/// +/// For the same reason each targeted service's `_invokeMethod` must reach its +/// gated call before its own first `await`. +void runClientActions(BuildContext context, dynamic actions) { + if (actions == null) return; + var backend = FletBackend.of(context); + for (var action in actions is List ? actions : [actions]) { + if (action is! Map) continue; + var serviceId = action["service_id"]; + var method = action["method"]; + var service = backend.controlsIndex.get(serviceId); + + // Skip rather than await: invokeMethod() waits for a listener when the + // service is not mounted yet, and that wait would consume the gesture and + // leave the action failing silently. + if (service == null || !service.hasInvokeMethodListeners) { + debugPrint( + "Client action target is not available: $method on service $serviceId"); + continue; + } + + service + .invokeMethod(method, action["args"], _kClientActionTimeout) + .then((result) { + if (result != null) { + service.triggerEvent("result", result); + } + }).catchError((e) { + debugPrint("Client action $method failed: $e"); + }); + } +} + +/// Runs everything a control performs on the client when it is activated: its +/// `url`, then its `action`s, in that order. +/// +/// Call this from the control's tap/press callback in place of handling `url` +/// separately. The same synchronous-call rule as [runClientActions] applies - +/// `url` is subject to it too, since opening a new tab is gesture-gated as +/// well. +void runControlActions(BuildContext context, Control control) { + var url = control.getUrl("url"); + if (url != null) { + openWebBrowser(url); + } + runClientActions(context, control.get("action")); +} + +extension ClientActionParsers on Control { + /// Whether this control performs anything on the client when it is + /// activated - a `url` to open, or one or more `action`s. + /// + /// Controls that stay untappable unless something is wired to them use this + /// to decide whether to install a tap handler at all. + bool get hasControlActions => get("url") != null || get("action") != null; +} diff --git a/packages/flet/lib/src/utils/text.dart b/packages/flet/lib/src/utils/text.dart index 8a3ad5a567..b9700b2f3d 100644 --- a/packages/flet/lib/src/utils/text.dart +++ b/packages/flet/lib/src/utils/text.dart @@ -7,6 +7,7 @@ import '../models/control.dart'; import '../utils/box.dart'; import '../utils/drawing.dart'; import '../utils/numbers.dart'; +import 'client_actions.dart'; import 'colors.dart'; import 'enums.dart'; import 'launch_url.dart'; @@ -53,15 +54,17 @@ FontWeight? parseFontWeight(String? weightName, [FontWeight? defaultWeight]) { } List parseTextSpans(List spans, ThemeData theme, - [void Function(Control, String, [dynamic eventData])? sendControlEvent]) { + [void Function(Control, String, [dynamic eventData])? sendControlEvent, + BuildContext? context]) { return spans - .map((span) => parseInlineSpan(span, theme, sendControlEvent)) + .map((span) => parseInlineSpan(span, theme, sendControlEvent, context)) .nonNulls .toList(); } TextSpan? parseInlineSpan(Control span, ThemeData theme, - [void Function(Control, String, [dynamic eventData])? sendControlEvent]) { + [void Function(Control, String, [dynamic eventData])? sendControlEvent, + BuildContext? context]) { span.notifyParent = true; var onClick = span.hasEventHandler("click"); var url = span.getUrl("url"); @@ -71,18 +74,25 @@ TextSpan? parseInlineSpan(Control span, ThemeData theme, style: span.getTextStyle("style", theme), spellOut: span.getBool("spell_out"), semanticsLabel: span.getString("semantics_label"), - children: parseTextSpans(span.children("spans"), theme, sendControlEvent), + children: parseTextSpans( + span.children("spans"), theme, sendControlEvent, context), mouseCursor: onClick && !span.disabled && sendControlEvent != null ? SystemMouseCursors.click : null, - recognizer: - (onClick || url != null) && !span.disabled && sendControlEvent != null - ? (TapGestureRecognizer() - ..onTap = () { - if (url != null) openWebBrowser(url); - if (onClick) sendControlEvent(span, "click"); - }) - : null, + recognizer: (onClick || span.hasControlActions) && + !span.disabled && + sendControlEvent != null + ? (TapGestureRecognizer() + ..onTap = () { + if (url != null) openWebBrowser(url); + // Spans rendered outside a widget tree (e.g. on a Canvas) have no + // context to resolve the target service from; only `url` works there. + if (context != null) { + runClientActions(context, span.get("action")); + } + if (onClick) sendControlEvent(span, "click"); + }) + : null, onEnter: span.hasEventHandler("enter") && !span.disabled && sendControlEvent != null diff --git a/sdk/python/examples/services/url_launcher/open_url_action/main.py b/sdk/python/examples/services/url_launcher/open_url_action/main.py new file mode 100644 index 0000000000..ea23eea535 --- /dev/null +++ b/sdk/python/examples/services/url_launcher/open_url_action/main.py @@ -0,0 +1,43 @@ +import flet as ft + + +def main(page: ft.Page): + # `action` is performed by the client while it is still handling the click, + # so opening a new tab is not treated as an unsolicited popup. Compare with + # `UrlLauncher().launch_url()`, which has to reach Python first and is + # therefore blocked by Safari on iOS. + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text("Both buttons open the same page:"), + ft.Button( + "Open in this tab", + action=ft.OpenUrl("https://flet.dev"), + ), + ft.Button( + "Open in a new tab", + action=ft.OpenUrl( + "https://flet.dev", + target=ft.UrlTarget.BLANK, + ), + ), + ft.Text( + "An action can be combined with on_click - the action " + "runs on the client, then your handler runs in Python." + ), + ft.Button( + "Open and log", + action=ft.OpenUrl("https://flet.dev/docs"), + on_click=lambda e: page.show_dialog( + ft.SnackBar(ft.Text("Docs opened")) + ), + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml b/sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml new file mode 100644 index 0000000000..3990525752 --- /dev/null +++ b/sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "open-url-action" +version = "1.0.0" +description = "Opens URLs from a button using a client action, so new tabs are not blocked on iOS Safari." +requires-python = ">=3.10" +keywords = ["urllauncher", "clientaction", "basic", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/UrlLauncher", "Navigation/Routing"] + +[tool.flet.metadata] +title = "Open URL action" +controls = ["SafeArea", "Column", "Page", "Button", "Text", "OpenUrl", "UrlTarget", "SnackBar"] +layout_pattern = "inline-actions" +complexity = "basic" +features = ["client actions", "URL launching"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index ada04ca522..8a5b2926a5 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -114,6 +114,10 @@ ShapeBorder, StadiumBorder, ) + from flet.controls.client_action import ( + ClientAction, + OpenUrl, + ) from flet.controls.colors import Colors from flet.controls.context import ( Context, @@ -804,6 +808,7 @@ "CircleAvatar", "CircleBorder", "CircularRectangleNotchShape", + "ClientAction", "ClipBehavior", "Clipboard", "ColorFilter", @@ -1024,6 +1029,7 @@ "OffsetValue", "OnReorderEvent", "OnScrollEvent", + "OpenUrl", "Orientation", "OutlinedBorder", "OutlinedButton", @@ -1334,6 +1340,7 @@ "CircleAvatar": "flet.controls.material.circle_avatar", "CircleBorder": "flet.controls.buttons", "CircularRectangleNotchShape": "flet.controls.types", + "ClientAction": "flet.controls.client_action", "ClipBehavior": "flet.controls.types", "Clipboard": "flet.controls.services.clipboard", "ColorFilter": "flet.controls.box", @@ -1554,6 +1561,7 @@ "OffsetValue": "flet.controls.transform", "OnReorderEvent": "flet.controls.material.reorderable_list_view", "OnScrollEvent": "flet.controls.scrollable_control", + "OpenUrl": "flet.controls.client_action", "Orientation": "flet.controls.types", "OutlinedBorder": "flet.controls.buttons", "OutlinedButton": "flet.controls.material.outlined_button", diff --git a/sdk/python/packages/flet/src/flet/controls/client_action.py b/sdk/python/packages/flet/src/flet/controls/client_action.py new file mode 100644 index 0000000000..046a5a0741 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/client_action.py @@ -0,0 +1,142 @@ +from dataclasses import MISSING, dataclass, field +from typing import Any, Optional, TypeVar, Union + +from flet.controls.context import context +from flet.controls.services.service import Service +from flet.controls.types import Url, UrlTarget + +__all__ = [ + "ClientAction", + "OpenUrl", +] + +S = TypeVar("S", bound=Service) + + +def _shared_service(service_type: type[S]) -> S: + """ + Returns the page's single instance of `service_type`, creating it on first use. + + Client actions are attached to controls, so a page can easily hold dozens of + them. Instantiating a service per action would register a service per action + with the client for no benefit, since these services carry no per-action + state. + """ + page = context.page + services = page._internals.setdefault("client_action_services", {}) + service = services.get(service_type) + if service is None: + service = service_type() + services[service_type] = service + return service + + +def _action_field(default: Any = MISSING): + """ + Declares a field that configures an action but is not sent to the client. + + Only `service_id`, `method` and `args` cross the wire; the properties users + set are kept readable on the Python object without duplicating them into the + payload. Omit `default` to make the field required. + """ + if default is MISSING: + return field(metadata={"skip": True}) + return field(default=default, metadata={"skip": True}) + + +@dataclass +class ClientAction: + """ + Base class for actions performed by the client, without a round trip to your + Python code. + + Assign one - or a list of them - to the `action` property of a control such + as :class:`~flet.Button`, and it runs the moment the control is activated. + + Browsers only allow a page to open a file picker, write to the clipboard, + show a share sheet or open a new tab while the user's click or key press is + still being handled. Sending the click to your Python code and acting on the + reply takes longer than that permission lasts, so on iOS Safari those + operations are silently ignored, while Android and desktop browsers let them + through. Client actions close that gap by performing the operation on the + client, inside the original gesture. + + Because an action runs before your code sees the click, its arguments have to + be known in advance. To act on a value that is only computed at click time, + set it on the control ahead of the click instead. + + Note: + Actions are not constructed directly - use one of the subclasses, such as + :class:`~flet.OpenUrl`. + """ + + service_id: int = field(init=False, default=0) + """ + Internal id of the service that performs this action on the client. + """ + + method: str = field(init=False, default="") + """ + Name of the service method invoked on the client. + """ + + args: dict[str, Any] = field(init=False, default_factory=dict) + """ + Arguments passed to :attr:`method`. + """ + + _service: Optional[Service] = field( + init=False, default=None, repr=False, metadata={"skip": True} + ) + + def _bind(self, service: Service, method: str, args: dict[str, Any]) -> None: + """ + Targets this action at `method` of `service`. + + Holding on to `service` matters: the page drops services that nothing + references any more, and an action outlives the call that created it. + """ + self._service = service + self.service_id = service._i + self.method = method + self.args = {k: v for k, v in args.items() if v is not None} + + +@dataclass +class OpenUrl(ClientAction): + """ + Opens a URL when the control is activated. + + Equivalent to :meth:`flet.UrlLauncher.launch_url`, but performed by the + client inside the user's gesture, so that opening a new tab is not blocked as + an unsolicited popup. + + Example: + ```python + ft.Button( + "Open flet.dev", + action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK), + ) + ``` + """ + + url: str = _action_field() + """ + The URL to open. + """ + + target: Optional[Union[UrlTarget, str]] = _action_field(None) + """ + Where to open the URL, for example :attr:`flet.UrlTarget.BLANK` for a new tab. + + Web-only; ignored on other platforms. + """ + + def __post_init__(self) -> None: + from flet.controls.services.url_launcher import UrlLauncher + + self._bind( + _shared_service(UrlLauncher), + "launch_url", + {"url": Url(url=self.url, target=self.target)}, + ) diff --git a/sdk/python/packages/flet/src/flet/controls/core/text_span.py b/sdk/python/packages/flet/src/flet/controls/core/text_span.py index b912b308c6..a32b150fab 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/text_span.py +++ b/sdk/python/packages/flet/src/flet/controls/core/text_span.py @@ -1,6 +1,7 @@ from typing import Optional, Union from flet.controls.base_control import control +from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.text_style import TextStyle @@ -44,6 +45,17 @@ class TextSpan(Control): the `text` takes precedence. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this span is tapped, without a round + trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py index 06e6ce8deb..a23d286321 100644 --- a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py +++ b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py @@ -5,6 +5,7 @@ from flet.controls.alignment import Alignment from flet.controls.base_control import control from flet.controls.border_radius import BorderRadius, BorderRadiusValue +from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.geometry import Size from flet.controls.layout_control import LayoutControl @@ -131,6 +132,17 @@ class CupertinoButton(LayoutControl): The radius of the button's corners when it has a background color. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py index 7ff3a4c481..474653d750 100644 --- a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py +++ b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py @@ -1,6 +1,7 @@ from typing import Annotated, Optional, Union from flet.controls.base_control import control +from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.padding import PaddingValue @@ -85,6 +86,17 @@ class CupertinoListTile(LayoutControl): :attr:`trailing` controls. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/button.py b/sdk/python/packages/flet/src/flet/controls/material/button.py index 55bca37e00..0f507716e4 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/button.py @@ -4,6 +4,7 @@ from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle +from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.icon_data import IconData @@ -96,6 +97,17 @@ class Button(LayoutControl, AdaptiveControl): The button's clip behavior. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when the button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/container.py b/sdk/python/packages/flet/src/flet/controls/material/container.py index b46ba5c27f..fc99e8dcfc 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/container.py +++ b/sdk/python/packages/flet/src/flet/controls/material/container.py @@ -14,6 +14,7 @@ ColorFilter, DecorationImage, ) +from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler, EventHandler from flet.controls.events import TapEvent @@ -174,6 +175,17 @@ class Container(LayoutControl, AdaptiveControl): The shadow(s) below this container. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this container is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py b/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py index 1e6270b77c..1d37c17418 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py @@ -2,6 +2,7 @@ from flet.controls.base_control import control from flet.controls.buttons import OutlinedBorder +from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.icon_data import IconData @@ -171,6 +172,17 @@ class FloatingActionButton(LayoutControl): long-press will produce a short vibration. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/icon_button.py b/sdk/python/packages/flet/src/flet/controls/material/icon_button.py index fd5998713e..19ebe60e6b 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/icon_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/icon_button.py @@ -6,6 +6,7 @@ from flet.controls.base_control import control from flet.controls.box import BoxConstraints from flet.controls.buttons import ButtonStyle +from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.padding import PaddingValue @@ -170,6 +171,17 @@ class IconButton(LayoutControl, AdaptiveControl): long-press will produce a short vibration. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/list_tile.py b/sdk/python/packages/flet/src/flet/controls/material/list_tile.py index 035cc146bd..9550ced8d6 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/list_tile.py +++ b/sdk/python/packages/flet/src/flet/controls/material/list_tile.py @@ -4,6 +4,7 @@ from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import OutlinedBorder +from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.padding import PaddingValue @@ -245,6 +246,17 @@ class ListTile(LayoutControl, AdaptiveControl): The minimum padding on the top and bottom of the `title` and `subtitle` controls. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py b/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py index 631b07213f..ad1478ad2d 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py @@ -3,6 +3,7 @@ from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle +from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.icon_data import IconData @@ -73,6 +74,17 @@ class OutlinedButton(LayoutControl, AdaptiveControl): The content will be clipped (or not) according to this option. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/text_button.py b/sdk/python/packages/flet/src/flet/controls/material/text_button.py index 0d999a999b..9a567252be 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/text_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/text_button.py @@ -3,6 +3,7 @@ from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle +from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.types import ( @@ -60,6 +61,17 @@ class TextButton(LayoutControl, AdaptiveControl): added to the page will get focus. """ + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ + url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/website/docs/types/clientaction.md b/website/docs/types/clientaction.md new file mode 100644 index 0000000000..5fb544dd96 --- /dev/null +++ b/website/docs/types/clientaction.md @@ -0,0 +1,7 @@ +--- +title: "ClientAction" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/openurl.md b/website/docs/types/openurl.md new file mode 100644 index 0000000000..98ffbb6fd4 --- /dev/null +++ b/website/docs/types/openurl.md @@ -0,0 +1,7 @@ +--- +title: "OpenUrl" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/sidebars.yml b/website/sidebars.yml index 2bb133dd9e..fd60abeb80 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -43,6 +43,7 @@ docs: Authentication: cookbook/authentication.md Encrypting sensitive data: cookbook/encrypting-sensitive-data.md Declarative dialogs: cookbook/declarative-dialogs.md + Client actions: cookbook/client-actions.md Flet MCP server: cookbook/flet-mcp.md Publishing Flet app: _index: publish/index.md @@ -665,6 +666,8 @@ docs: TimePickerTheme: types/timepickertheme.md TooltipTheme: types/tooltiptheme.md - types/browserconfiguration.md + - types/clientaction.md + - types/openurl.md - types/tooltip.md - types/url.md - types/webviewconfiguration.md From 69da75563fa450aedb9d64adfe1caffb1c812ddb Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 28 Aug 2026 15:13:29 -0700 Subject: [PATCH 02/10] feat: CopyToClipboard, ShareText and PickFiles client actions PickFiles is the fix for #3710: it opens the file dialog from inside the tap, which is the only moment WebKit permits one. The selection cannot be returned to a caller that does not exist, so it arrives at the new FilePicker.on_result event; the files stay on the FilePicker service, so upload() is unchanged. The dispatcher marks gesture-initiated invocations with _from_gesture so a service can tell the two entry points apart. FilePickerService uses it twice: to emit on_result only for the gesture path, and to fail fast when pick_files() is called from Python on a browser that has already reported it will not open the dialog - previously that hung until the 3600s invoke-method timeout with no error at all. isGestureGatedDialogBlocked() only reports true for Apple/WebKit browsers with no live user activation. Chrome and Firefox allow programmatic file input clicks outside a gesture, so treating them as blocked would break apps that work today. --- .../flet/lib/src/services/file_picker.dart | 28 ++- .../flet/lib/src/utils/client_actions.dart | 15 +- .../lib/src/utils/platform_utils_non_web.dart | 4 + .../lib/src/utils/platform_utils_web.dart | 17 ++ sdk/python/packages/flet/src/flet/__init__.py | 12 ++ .../flet/src/flet/controls/client_action.py | 173 +++++++++++++++++- .../src/flet/controls/services/file_picker.py | 29 +++ 7 files changed, 268 insertions(+), 10 deletions(-) diff --git a/packages/flet/lib/src/services/file_picker.dart b/packages/flet/lib/src/services/file_picker.dart index 1c7c0560e9..a410e76523 100644 --- a/packages/flet/lib/src/services/file_picker.dart +++ b/packages/flet/lib/src/services/file_picker.dart @@ -7,6 +7,8 @@ import '../flet_service.dart'; import '../utils/file_picker.dart'; import '../utils/numbers.dart'; import '../utils/platform.dart'; +import '../utils/platform_utils_web.dart' + if (dart.library.io) '../utils/platform_utils_non_web.dart'; class FilePickerService extends FletService { FilePickerService({required super.control}); @@ -51,6 +53,25 @@ class FilePickerService extends FletService { uploadFiles(files, control.backend.pageUri); } case "pick_files": + var fromGesture = args["_from_gesture"] == true; + + // Not reached from a gesture and the browser has already told us it + // will not open the dialog: fail now with something actionable, rather + // than leaving the caller waiting out its timeout on nothing. + if (!fromGesture && isGestureGatedDialogBlocked()) { + throw Exception( + "This browser only opens a file picker while it is handling a " + "user's click, so pick_files() cannot open one - by the time it " + "reaches your Python code the click is over. Attach " + "ft.PickFiles(...) to the control's `action` property instead " + "and handle FilePicker.on_result. " + "See https://flet.dev/docs/cookbook/client-actions"); + } + + // Note for future edits: nothing may be awaited between entering this + // method and the FilePicker.pickFiles() call below. On the gesture + // path an async gap discards the browser's user activation and the + // dialog silently never opens. See flet-dev/flet#3710. _files = (await FilePicker.pickFiles( dialogTitle: dialogTitle, initialDirectory: initialDirectory, @@ -63,7 +84,7 @@ class FilePickerService extends FletService { withReadStream: !withData, cancelUploadOnWindowBlur: cancelUploadOnWindowBlur)) ?.files; - return _files != null + var pickedFiles = _files != null ? _files!.asMap().entries.map((file) { return FilePickerFile( id: file.key, // use entry's index as id @@ -74,6 +95,11 @@ class FilePickerService extends FletService { .toMap(); }).toList() : []; + if (fromGesture) { + // A gesture-triggered pick has no caller to return to. + control.triggerEvent("result", {"files": pickedFiles}); + } + return pickedFiles; case "save_file": if ((kIsWeb || isAndroidMobile() || isIOSMobile()) && srcBytes == null) { diff --git a/packages/flet/lib/src/utils/client_actions.dart b/packages/flet/lib/src/utils/client_actions.dart index 99c09533dd..bf064de0d8 100644 --- a/packages/flet/lib/src/utils/client_actions.dart +++ b/packages/flet/lib/src/utils/client_actions.dart @@ -39,15 +39,14 @@ void runClientActions(BuildContext context, dynamic actions) { continue; } + // Services distinguish the two entry points: reaching them from a gesture + // is what makes gesture-gated APIs work, and a result cannot be returned + // to a caller that does not exist, so services report it as an event. + var args = {...?(action["args"] as Map?), "_from_gesture": true}; + service - .invokeMethod(method, action["args"], _kClientActionTimeout) - .then((result) { - if (result != null) { - service.triggerEvent("result", result); - } - }).catchError((e) { - debugPrint("Client action $method failed: $e"); - }); + .invokeMethod(method, args, _kClientActionTimeout) + .catchError((e) => debugPrint("Client action $method failed: $e")); } } diff --git a/packages/flet/lib/src/utils/platform_utils_non_web.dart b/packages/flet/lib/src/utils/platform_utils_non_web.dart index a94f6a8ba4..5b4def9acb 100644 --- a/packages/flet/lib/src/utils/platform_utils_non_web.dart +++ b/packages/flet/lib/src/utils/platform_utils_non_web.dart @@ -34,3 +34,7 @@ Map getViewInitialData(int viewId) { void openPopupBrowserWindow( String url, String windowName, int minWidth, int minHeight) {} + +bool isGestureGatedDialogBlocked() { + return false; +} diff --git a/packages/flet/lib/src/utils/platform_utils_web.dart b/packages/flet/lib/src/utils/platform_utils_web.dart index 235d360650..ba22660746 100644 --- a/packages/flet/lib/src/utils/platform_utils_web.dart +++ b/packages/flet/lib/src/utils/platform_utils_web.dart @@ -77,3 +77,20 @@ void openPopupBrowserWindow( web.window.open(url, windowName, "top=$top,left=$left,width=$width,height=$height,scrollbars=yes"); } + +/// Whether the browser will refuse to open a gesture-gated dialog, such as a +/// file picker, right now. +/// +/// Only reports `true` when that is certain: an Apple (WebKit) browser that +/// says no user activation is live. Chrome and Firefox let a page click a file +/// input outside a gesture, so they are never reported as blocked - guessing +/// there would break apps that work today. +bool isGestureGatedDialogBlocked() { + try { + if (!web.window.navigator.vendor.startsWith("Apple")) return false; + return !web.window.navigator.userActivation.isActive; + } catch (_) { + // userActivation is unsupported on older browsers - never guess. + return false; + } +} diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 8a5b2926a5..7e04ba290f 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -116,7 +116,10 @@ ) from flet.controls.client_action import ( ClientAction, + CopyToClipboard, OpenUrl, + PickFiles, + ShareText, ) from flet.controls.colors import Colors from flet.controls.context import ( @@ -569,6 +572,7 @@ FilePicker, FilePickerFile, FilePickerFileType, + FilePickerResultEvent, FilePickerUploadEvent, FilePickerUploadFile, ) @@ -833,6 +837,7 @@ "ControlEventHandler", "ControlState", "ControlStateValue", + "CopyToClipboard", "CrossAxisAlignment", "CupertinoActionSheet", "CupertinoActionSheetAction", @@ -916,6 +921,7 @@ "FilePicker", "FilePickerFile", "FilePickerFileType", + "FilePickerResultEvent", "FilePickerUploadEvent", "FilePickerUploadFile", "FilledButton", @@ -1052,6 +1058,7 @@ "PaintRadialGradient", "PaintSweepGradient", "PaintingStyle", + "PickFiles", "Placeholder", "PlatformBrightnessChangeEvent", "PointerDeviceType", @@ -1125,6 +1132,7 @@ "ShareFile", "ShareResult", "ShareResultStatus", + "ShareText", "SharedPreferences", "Shimmer", "ShimmerDirection", @@ -1365,6 +1373,7 @@ "ControlEventHandler": "flet.controls.control_event", "ControlState": "flet.controls.control_state", "ControlStateValue": "flet.controls.control_state", + "CopyToClipboard": "flet.controls.client_action", "CrossAxisAlignment": "flet.controls.types", "CupertinoActionSheet": "flet.controls.cupertino.cupertino_action_sheet", "CupertinoActionSheetAction": "flet.controls.cupertino.cupertino_action_sheet_action", # noqa: E501 @@ -1448,6 +1457,7 @@ "FilePicker": "flet.controls.services.file_picker", "FilePickerFile": "flet.controls.services.file_picker", "FilePickerFileType": "flet.controls.services.file_picker", + "FilePickerResultEvent": "flet.controls.services.file_picker", "FilePickerUploadEvent": "flet.controls.services.file_picker", "FilePickerUploadFile": "flet.controls.services.file_picker", "FilledButton": "flet.controls.material.filled_button", @@ -1584,6 +1594,7 @@ "PaintRadialGradient": "flet.controls.painting", "PaintSweepGradient": "flet.controls.painting", "PaintingStyle": "flet.controls.painting", + "PickFiles": "flet.controls.client_action", "Placeholder": "flet.controls.core.placeholder", "PlatformBrightnessChangeEvent": "flet.controls.page", "PointerDeviceType": "flet.controls.types", @@ -1657,6 +1668,7 @@ "ShareFile": "flet.controls.services.share", "ShareResult": "flet.controls.services.share", "ShareResultStatus": "flet.controls.services.share", + "ShareText": "flet.controls.client_action", "SharedPreferences": "flet.controls.services.shared_preferences", "Shimmer": "flet.controls.core.shimmer", "ShimmerDirection": "flet.controls.core.shimmer", diff --git a/sdk/python/packages/flet/src/flet/controls/client_action.py b/sdk/python/packages/flet/src/flet/controls/client_action.py index 046a5a0741..22d585a13c 100644 --- a/sdk/python/packages/flet/src/flet/controls/client_action.py +++ b/sdk/python/packages/flet/src/flet/controls/client_action.py @@ -1,13 +1,19 @@ from dataclasses import MISSING, dataclass, field -from typing import Any, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union from flet.controls.context import context from flet.controls.services.service import Service from flet.controls.types import Url, UrlTarget +if TYPE_CHECKING: + from flet.controls.services.file_picker import FilePicker, FilePickerFileType + __all__ = [ "ClientAction", + "CopyToClipboard", "OpenUrl", + "PickFiles", + "ShareText", ] S = TypeVar("S", bound=Service) @@ -140,3 +146,168 @@ def __post_init__(self) -> None: "launch_url", {"url": Url(url=self.url, target=self.target)}, ) + + +@dataclass +class CopyToClipboard(ClientAction): + """ + Copies text to the clipboard when the control is activated. + + Equivalent to :meth:`flet.Clipboard.set`, but performed by the client inside + the user's gesture, which is the only time Safari permits a page to write to + the clipboard. + + Example: + ```python + ft.Button("Copy token", action=ft.CopyToClipboard(token)) + ``` + """ + + data: str = _action_field() + """ + The text to copy. + + Must be known before the control is activated. To copy something computed at + click time, assign it to this action ahead of the click. + """ + + def __post_init__(self) -> None: + from flet.controls.services.clipboard import Clipboard + + self._bind(_shared_service(Clipboard), "set", {"data": self.data}) + + +@dataclass +class PickFiles(ClientAction): + """ + Opens a file picker dialog when the control is activated. + + Equivalent to :meth:`flet.FilePicker.pick_files`, but performed by the + client inside the user's gesture, which is the only time a browser opens a + file picker. This is what makes file picking work in a web app on iOS. + + Unlike `pick_files()`, the selection is not returned to the caller - it + arrives at :attr:`flet.FilePicker.on_result`. The files stay associated with + `file_picker`, so they can be passed straight to + :meth:`flet.FilePicker.upload`. + + Example: + ```python + picker = ft.FilePicker(on_result=handle_result) + page.services.append(picker) + + ft.Button("Upload", action=ft.PickFiles(picker, allow_multiple=True)) + ``` + """ + + file_picker: "FilePicker" = _action_field() + """ + The :class:`~flet.FilePicker` that opens the dialog and reports the result + through its :attr:`~flet.FilePicker.on_result` event. + """ + + dialog_title: Optional[str] = _action_field(None) + """ + The title of the dialog window. + """ + + initial_directory: Optional[str] = _action_field(None) + """ + The initial directory where the dialog should open. + """ + + file_type: Optional["FilePickerFileType"] = _action_field(None) + """ + The file types allowed to be selected. + """ + + allowed_extensions: Optional[list[str]] = _action_field(None) + """ + The allowed file extensions. Has effect only if :attr:`file_type` is + :attr:`flet.FilePickerFileType.CUSTOM`. + """ + + allow_multiple: bool = _action_field(False) + """ + Allow the selection of multiple files at once. + """ + + with_data: bool = _action_field(False) + """ + Read selected file contents into :attr:`flet.FilePickerFile.bytes`. + """ + + compression_quality: int = _action_field(0) + """ + Image compression quality from `0` to `100`. `0` disables compression. + """ + + cancel_upload_on_window_blur: bool = _action_field(True) + """ + Web-only. Whether to treat browser window blur as a cancelled selection. + """ + + def __post_init__(self) -> None: + from flet.controls.services.file_picker import FilePickerFileType + + self._bind( + self.file_picker, + "pick_files", + { + "dialog_title": self.dialog_title, + "initial_directory": self.initial_directory, + "file_type": self.file_type or FilePickerFileType.ANY, + "allowed_extensions": self.allowed_extensions, + "allow_multiple": self.allow_multiple, + "with_data": self.with_data, + "compression_quality": self.compression_quality, + "cancel_upload_on_window_blur": self.cancel_upload_on_window_blur, + }, + ) + + +@dataclass +class ShareText(ClientAction): + """ + Opens the platform share sheet with text when the control is activated. + + Equivalent to :meth:`flet.Share.share_text`, but performed by the client + inside the user's gesture, which is the only time a browser permits + `navigator.share`. + + The share result is not reported back - use + :meth:`flet.Share.share_text` if you need it, keeping in mind that it does + not work on the web on iOS. + + Example: + ```python + ft.Button( + "Share", + action=ft.ShareText("Check out Flet", subject="Flet"), + ) + ``` + """ + + text: str = _action_field() + """ + The text to share. + """ + + title: Optional[str] = _action_field(None) + """ + Title shown in the share sheet. + """ + + subject: Optional[str] = _action_field(None) + """ + Subject used by targets that support one, such as email. + """ + + def __post_init__(self) -> None: + from flet.controls.services.share import Share, _share_args + + self._bind( + _shared_service(Share), + "share_text", + _share_args(text=self.text, title=self.title, subject=self.subject), + ) diff --git a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py index a3fdb02c66..f543ec6c0c 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py +++ b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py @@ -11,6 +11,7 @@ "FilePicker", "FilePickerFile", "FilePickerFileType", + "FilePickerResultEvent", "FilePickerUploadEvent", "FilePickerUploadFile", ] @@ -140,6 +141,22 @@ class FilePickerFile: """ +@dataclass +class FilePickerResultEvent(Event["FilePicker"]): + """ + Event emitted when files are selected through a + :class:`~flet.PickFiles` action. + + Not emitted by :meth:`flet.FilePicker.pick_files`, which returns the + selected files directly. + """ + + files: list[FilePickerFile] + """ + The selected files, or an empty list if the user cancelled. + """ + + @dataclass class FilePickerUploadEvent(Event["FilePicker"]): """ @@ -180,6 +197,18 @@ class FilePicker(Service): ``` """ + on_result: Optional[EventHandler[FilePickerResultEvent]] = None + """ + Called when files are selected through a :class:`~flet.PickFiles` action. + + A `PickFiles` action opens the dialog on the client before your code sees + the click, so the selection cannot be returned to the caller the way + :meth:`pick_files` returns it - it arrives here instead. + + The picked files stay associated with this `FilePicker`, so they can be + passed straight to :meth:`upload`. + """ + on_upload: Optional[EventHandler[FilePickerUploadEvent]] = None """ Called when a file is uploaded via :meth:`upload` method. From 24513994cf5965f8d3e4aeafedf192072e9e2b89 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 28 Aug 2026 15:17:28 -0700 Subject: [PATCH 03/10] docs: client actions cookbook, type reference and examples Adds the Client actions cookbook article, reference pages for ClientAction, OpenUrl, CopyToClipboard, ShareText, PickFiles and FilePickerResultEvent, and one runnable example per action alongside the existing examples for the service it exercises. The FilePicker, Clipboard, Share and UrlLauncher pages each gain a warning explaining why the imperative call does nothing in a web app on iOS, and show the action form instead. The FilePicker usage snippet also had an unterminated string. --- CHANGELOG.md | 4 + packages/flet/CHANGELOG.md | 1 + .../services/clipboard/copy_action/main.py | 51 +++++++++ .../clipboard/copy_action/pyproject.toml | 26 +++++ .../file_picker/pick_files_action/main.py | 87 +++++++++++++++ .../pick_files_action/pyproject.toml | 26 +++++ .../services/share/share_text_action/main.py | 34 ++++++ .../share/share_text_action/pyproject.toml | 26 +++++ website/docs/cookbook/client-actions.md | 101 ++++++++++++++++++ website/docs/services/clipboard.md | 16 +++ website/docs/services/filepicker.md | 25 ++++- website/docs/services/share.md | 14 +++ website/docs/services/urllauncher.md | 15 +++ website/docs/types/copytoclipboard.md | 7 ++ website/docs/types/filepickerresultevent.md | 7 ++ website/docs/types/pickfiles.md | 7 ++ website/docs/types/sharetext.md | 7 ++ website/sidebars.yml | 4 + 18 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 sdk/python/examples/services/clipboard/copy_action/main.py create mode 100644 sdk/python/examples/services/clipboard/copy_action/pyproject.toml create mode 100644 sdk/python/examples/services/file_picker/pick_files_action/main.py create mode 100644 sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml create mode 100644 sdk/python/examples/services/share/share_text_action/main.py create mode 100644 sdk/python/examples/services/share/share_text_action/pyproject.toml create mode 100644 website/docs/cookbook/client-actions.md create mode 100644 website/docs/types/copytoclipboard.md create mode 100644 website/docs/types/filepickerresultevent.md create mode 100644 website/docs/types/pickfiles.md create mode 100644 website/docs/types/sharetext.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ca17bcc4e3..9f639d83a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Improvements +* `FilePicker` gained an `on_result` event, called when files are selected through a `PickFiles` action. A `PickFiles` action opens the dialog on the client before your code sees the click, so the selection cannot be returned to the caller the way `pick_files()` returns it - it arrives here instead. The picked files stay associated with the `FilePicker`, so they can be passed straight to `upload()` by @FeodorFitsner. + * Web builds and `flet publish` now read the `FLET_WEB_RENDERER`, `FLET_WEB_ROUTE_URL_STRATEGY`, and `FLET_WEB_NO_CDN` environment variables as fallbacks behind the CLI options and `[tool.flet.web]` pyproject keys, matching the `[env: ...]` notation the options already advertised, by @ndonkoHenri. * `flet build web` and `flet publish` no longer bundle CanvasKit and Pyodide when CDN mode is on (the default), taking a minimal web build from **71 MB to 19 MB**. In CDN mode Flutter loads CanvasKit from `gstatic.com` and Flet points `pyodideUrl` at jsdelivr, so both copies were dead weight the browser never requested — yet `flutter build web` always emits `canvaskit/` (~37 MB), and `ensure_pyodide()` ran unconditionally in both commands, downloading and copying a further ~15 MB. Neither is fetched, so nothing about how a CDN-mode app loads changes; verified with a network log showing the built app pulling `chromium/canvaskit.{js,wasm}` from gstatic and the full Pyodide runtime from jsdelivr, with no request to a local `canvaskit/` or `pyodide/` path. `--no-cdn` (or `[tool.flet.web] cdn = false`) still bundles everything and is unchanged. `flet build` also clears a `pyodide/` left in the reused Flutter project by an earlier `--no-cdn` build, so switching modes doesn't silently keep shipping it by @FeodorFitsner. * `flet.canvasKitBaseUrl` and `flet.fontFallbackBaseUrl` are now honored whenever they are set, instead of only when `flet.noCdn` is true. Previously `flutter_bootstrap.js` applied both inside `if (flet.noCdn)`, so a host serving its own copy of the runtime — a CDN-restricted network, an air-gapped deployment, or a platform that mirrors the runtime on its own origin — had to also set `noCdn` for the assignment to take effect at all, and assigning the URL alone failed silently by booting off gstatic anyway. Where a runtime asset is fetched from is now independent of what the build bundled: both values default to `null` in CDN mode and are pinned by `flet build`/`patch_index.py` only when bundling by @FeodorFitsner. @@ -37,6 +39,8 @@ ### Bug fixes +* Fix `FilePicker.pick_files()` never opening a dialog in a web app on iOS, and `Clipboard.set()`, `Clipboard.set_image()` and the `Share` methods doing nothing there either. A browser opens a file picker, writes to the clipboard or shows a share sheet only while it is handling the user's click or key press, and a service method called from an event handler misses that window entirely: the click travels to Python, the handler runs, and the instruction travels back long after the permission has lapsed. WebKit enforces this and reports nothing, while Chrome and Firefox allow the same calls, so an app worked on Android and on the desktop and silently did nothing on an iPhone or iPad - which made a browser rule look like a Flet bug, and made `FilePicker` in particular look broken since `save_file()` kept working (it clicks a download link, which is not gated). These operations are now available as client actions - `ft.PickFiles`, `ft.CopyToClipboard`, `ft.ShareText`, `ft.OpenUrl` - assigned to a control's `action` property and performed by the client inside the original gesture. `pick_files()` also no longer hangs for its full one-hour timeout when a browser has already reported it will not open the dialog: it raises straight away, pointing at `ft.PickFiles` ([#3710](https://github.com/flet-dev/flet/issues/3710)) 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 (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, 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. diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index 31af5d808c..295bca28ed 100644 --- a/packages/flet/CHANGELOG.md +++ b/packages/flet/CHANGELOG.md @@ -1,6 +1,7 @@ ## 1.0.0 * Add `runClientActions()` and `runControlActions()` in `utils/client_actions.dart`, which perform a control's `url` and `action` properties on the client, synchronously, from inside the gesture callback that triggered them. `runClientActions()` resolves each action's target service through `FletBackend.controlsIndex` and calls `Control.invokeMethod()` on it without awaiting, because browsers grant gesture-gated APIs - opening a file picker, writing to the clipboard, `navigator.share`, `window.open` - only while user activation is live, and activation does not survive an async gap. The new `Control.hasInvokeMethodListeners` getter guards that path: `Control.invokeMethod()` waits for a listener when the target service is not mounted yet, and awaiting that wait would silently consume the gesture, so an unresolvable action is skipped and logged instead. A new `Control.hasControlActions` extension getter reports whether a control has anything to run, for controls that only install a tap handler when something is wired to them. `openWebBrowser()` is no longer called directly by `Button`, `Container`, `IconButton`, `ListTile`, `CupertinoListTile`, `CupertinoButton` and `FloatingActionButton`, which now dispatch through `runControlActions()`; `parseTextSpans()` and `parseInlineSpan()` take an optional trailing `BuildContext` so a `TextSpan` can carry actions too. +* Client actions add `"_from_gesture": true` to the arguments they invoke a service method with, so a service can tell the two entry points apart. `FilePickerService` uses it to emit a `result` event only for the gesture path - a gesture-triggered pick has no caller to return to - and to skip the new `isGestureGatedDialogBlocked()` check, added to `utils/platform_utils_web.dart` and its non-web counterpart. That check reports `true` only for an Apple/WebKit browser whose `navigator.userActivation` says no activation is live, so a `pick_files()` call that cannot possibly open a dialog fails immediately instead of waiting out its timeout; Chrome and Firefox permit programmatic file input clicks outside a gesture and are never reported as blocked. ## 0.86.7 diff --git a/sdk/python/examples/services/clipboard/copy_action/main.py b/sdk/python/examples/services/clipboard/copy_action/main.py new file mode 100644 index 0000000000..2bf4325a23 --- /dev/null +++ b/sdk/python/examples/services/clipboard/copy_action/main.py @@ -0,0 +1,51 @@ +import flet as ft + + +def main(page: ft.Page): + # `action` is performed by the client while it is still handling the click. + # That is the only moment Safari lets a page write to the clipboard, which + # is why `Clipboard().set()` - which has to reach Python first - does + # nothing on iOS. + token = "flet-1234-5678" + + def handle_copied(e): + page.show_dialog(ft.SnackBar(ft.Text("Copied to clipboard"))) + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text(f"Token: {token}", selectable=True), + ft.Button( + "Copy token", + icon=ft.Icons.CONTENT_COPY, + action=ft.CopyToClipboard(token), + on_click=handle_copied, + ), + ft.Divider(), + ft.Text( + "An action's arguments are fixed before the click, so " + "to copy something typed just now, update the action " + "as the text changes." + ), + note := ft.TextField( + label="Note", + value="Edit me, then copy", + on_change=lambda e: setattr( + copy_note, "action", ft.CopyToClipboard(note.value) + ), + ), + copy_note := ft.Button( + "Copy note", + icon=ft.Icons.CONTENT_COPY, + action=ft.CopyToClipboard("Edit me, then copy"), + on_click=handle_copied, + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/clipboard/copy_action/pyproject.toml b/sdk/python/examples/services/clipboard/copy_action/pyproject.toml new file mode 100644 index 0000000000..a02a1e944c --- /dev/null +++ b/sdk/python/examples/services/clipboard/copy_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "copy-action" +version = "1.0.0" +description = "Copies text to the clipboard using a client action, which is the only form that works on iOS Safari." +requires-python = ">=3.10" +keywords = ["clipboard", "clientaction", "basic", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/Clipboard"] + +[tool.flet.metadata] +title = "Copy to clipboard action" +controls = ["SafeArea", "Column", "Page", "Button", "Text", "TextField", "CopyToClipboard", "SnackBar", "Divider"] +layout_pattern = "inline-actions" +complexity = "basic" +features = ["client actions", "clipboard"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/examples/services/file_picker/pick_files_action/main.py b/sdk/python/examples/services/file_picker/pick_files_action/main.py new file mode 100644 index 0000000000..fed1f5655d --- /dev/null +++ b/sdk/python/examples/services/file_picker/pick_files_action/main.py @@ -0,0 +1,87 @@ +# +# Picking and uploading files in a way that also works in a web app on iOS. +# +# Run this example with: +# export FLET_SECRET_KEY= +# uv run flet run --web examples/services/file_picker/pick_files_action/main.py +# +from dataclasses import dataclass, field + +import flet as ft + + +@dataclass +class State: + picked_files: list[ft.FilePickerFile] = field(default_factory=list) + + +state = State() + + +def main(page: ft.Page): + prog_bars: dict[str, ft.ProgressRing] = {} + + def handle_upload_progress(e: ft.FilePickerUploadEvent): + prog_bars[e.file_name].value = e.progress + + def handle_result(e: ft.FilePickerResultEvent): + # A PickFiles action opens the dialog before Python sees the click, so + # the selection arrives here instead of being returned to a caller. + state.picked_files = e.files + + prog_bars.clear() + upload_progress.controls.clear() + for f in e.files: + prog = ft.ProgressRing(value=0, bgcolor="#eeeeee", width=20, height=20) + prog_bars[f.name] = prog + upload_progress.controls.append( + ft.Row([prog, ft.Text(f"{f.name} ({f.size} bytes)")]) + ) + upload_button.disabled = len(e.files) == 0 + + async def handle_file_upload(e: ft.Event[ft.Button]): + upload_button.disabled = True + # The picked files stay on the FilePicker, so upload() takes them as-is. + await file_picker.upload( + files=[ + ft.FilePickerUploadFile( + name=file.name, + upload_url=page.get_upload_url(f"dir/{file.name}", 60), + ) + for file in state.picked_files + ] + ) + + file_picker = ft.FilePicker( + on_result=handle_result, + on_upload=handle_upload_progress, + ) + page.services.append(file_picker) + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Button( + content="Select files...", + icon=ft.Icons.FOLDER_OPEN, + # Attaching the pick to the control - rather than + # calling file_picker.pick_files() from an on_click + # handler - is what makes the dialog open on iOS. + action=ft.PickFiles(file_picker, allow_multiple=True), + ), + upload_progress := ft.Column(), + upload_button := ft.Button( + content="Upload", + icon=ft.Icons.UPLOAD, + on_click=handle_file_upload, + disabled=True, + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main, upload_dir="examples") diff --git a/sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml b/sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml new file mode 100644 index 0000000000..0c730aafdb --- /dev/null +++ b/sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "pick-files-action" +version = "1.0.0" +description = "Picks and uploads files using a client action, so the dialog also opens in a web app on iOS." +requires-python = ">=3.10" +keywords = ["filepicker", "clientaction", "upload", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/FilePicker"] + +[tool.flet.metadata] +title = "Pick files action" +controls = ["SafeArea", "Column", "Row", "Page", "Button", "Text", "ProgressRing", "FilePicker", "PickFiles", "FilePickerUploadFile"] +layout_pattern = "inline-actions" +complexity = "intermediate" +features = ["client actions", "file picking", "file upload"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/examples/services/share/share_text_action/main.py b/sdk/python/examples/services/share/share_text_action/main.py new file mode 100644 index 0000000000..43dea94c27 --- /dev/null +++ b/sdk/python/examples/services/share/share_text_action/main.py @@ -0,0 +1,34 @@ +import flet as ft + + +def main(page: ft.Page): + # `action` is performed by the client while it is still handling the click. + # Browsers only open the share sheet during a gesture, so `Share()` called + # from Python has no effect on the web. + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text("Share this page with someone:"), + ft.Button( + "Share", + icon=ft.Icons.SHARE, + action=ft.ShareText( + "Flet lets you build multi-platform apps in Python: " + "https://flet.dev", + subject="Flet", + ), + ), + ft.Text( + "The share sheet is a system dialog - what it offers " + "depends on the platform, and on desktop browsers it " + "may not be available at all." + ), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/services/share/share_text_action/pyproject.toml b/sdk/python/examples/services/share/share_text_action/pyproject.toml new file mode 100644 index 0000000000..0630da327c --- /dev/null +++ b/sdk/python/examples/services/share/share_text_action/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "share-text-action" +version = "1.0.0" +description = "Opens the platform share sheet from a button using a client action." +requires-python = ">=3.10" +keywords = ["share", "clientaction", "basic", "services"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Utility/Share"] + +[tool.flet.metadata] +title = "Share text action" +controls = ["SafeArea", "Column", "Page", "Button", "Text", "ShareText"] +layout_pattern = "inline-actions" +complexity = "basic" +features = ["client actions", "share sheet"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/website/docs/cookbook/client-actions.md b/website/docs/cookbook/client-actions.md new file mode 100644 index 0000000000..f305c180bd --- /dev/null +++ b/website/docs/cookbook/client-actions.md @@ -0,0 +1,101 @@ +--- +title: "Client actions" +--- + +import {CodeExample} from '@site/src/components/crocodocs'; + +Some things a browser can do are only allowed while it is handling a click or a +key press: opening a file picker, writing to the clipboard, showing a share +sheet, opening a new tab. The permission lasts for that one gesture and no +longer. + +That is a problem for the usual Flet pattern. When you call +[`FilePicker.pick_files()`](../services/filepicker.md#flet.FilePicker.pick_files) +from an `on_click` handler, the click travels to your Python code, your code +runs, and the instruction to open the dialog travels back - by which point the +browser no longer considers a gesture to be in progress and quietly refuses. + +Safari enforces this strictly, Chrome and Firefox are lenient about it, so the +symptom is confusing: the same app works on Android and on the desktop, and +silently does nothing on an iPhone or iPad. Nothing is logged, because from the +browser's point of view nothing went wrong. + +**Client actions** close that gap. An action is attached to a control instead of +being called from a handler, so the client already knows what to do when the tap +arrives and can do it immediately, inside the gesture: + +```python +ft.Button("Upload", action=ft.PickFiles(file_picker, allow_multiple=True)) +``` + +## Opening a URL + +[`OpenUrl`](../types/openurl.md) opens a link. Opening a *new tab* is the part +that browsers guard, since that is what a popup blocker exists to stop. + + + +The [`url`](../controls/button.md#flet.Button.url) property that controls have +always had works the same way and is unchanged - `OpenUrl` is for when you want +to combine it with other actions, or keep every gesture-gated operation written +the same way. + +## Copying to the clipboard + + + +## Sharing + + + +## Picking files + +[`PickFiles`](../types/pickfiles.md) is the action that fixes file picking in a +web app on iOS. + +Because the dialog opens before your code sees the click, the selection cannot +be returned to a caller the way `pick_files()` returns it. It arrives at +[`FilePicker.on_result`](../services/filepicker.md#flet.FilePicker.on_result) +instead. The picked files stay associated with the `FilePicker`, so +[`upload()`](../services/filepicker.md#flet.FilePicker.upload) works exactly as +before. + + + +## What each action maps to + +| Action | Equivalent method | +|---|---| +| [`OpenUrl`](../types/openurl.md) | [`UrlLauncher.launch_url()`](../services/urllauncher.md#flet.UrlLauncher.launch_url) | +| [`CopyToClipboard`](../types/copytoclipboard.md) | [`Clipboard.set()`](../services/clipboard.md#flet.Clipboard.set) | +| [`ShareText`](../types/sharetext.md) | [`Share.share_text()`](../services/share.md#flet.Share.share_text) | +| [`PickFiles`](../types/pickfiles.md) | [`FilePicker.pick_files()`](../services/filepicker.md#flet.FilePicker.pick_files) | + +An action runs first, then your `on_click` handler is called as usual - so you +can still react to the click in Python. + +A control accepts a list as well as a single action, if you need more than one: + +```python +ft.Button( + "Copy and open", + action=[ft.CopyToClipboard(link), ft.OpenUrl(link, target=ft.UrlTarget.BLANK)], +) +``` + +## Limits + +**An action's arguments are fixed before the click.** This follows from what an +action is - the client has to know the whole operation in advance, because there +is no time to ask. To copy or share a value that changes, update the action when +the value changes, as the clipboard example above does. There is no way around +this; it is the browser's rule, not Flet's. + +**Reading the clipboard still prompts.** Safari shows a paste-confirmation UI +for [`Clipboard.get()`](../services/clipboard.md#flet.Clipboard.get) whatever you +do. An action makes the read possible, not invisible. + +**Outside the browser this does not apply.** On desktop, Android and iOS apps +there is no such restriction, and the ordinary method calls work fine. Actions +work everywhere, so you can use them unconditionally if your app also runs on +the web - but there is nothing to fix if it does not. diff --git a/website/docs/services/clipboard.md b/website/docs/services/clipboard.md index 78e244d84c..0ba2d4c734 100644 --- a/website/docs/services/clipboard.md +++ b/website/docs/services/clipboard.md @@ -8,6 +8,22 @@ import {ClassMembers, ClassSummary, CodeExample} from '@site/src/components/croc +:::warning[Copying in a web app] +Browsers only let a page write to the clipboard while they are handling a click +or key press, and calling [`set()`](clipboard.md#flet.Clipboard.set) from an +event handler is already too late - the click has travelled to your Python code +and back. Safari refuses silently; Chrome and Firefox are more forgiving, so the +same app often works everywhere except on iPhone and iPad. + +Use a [`CopyToClipboard`](../types/copytoclipboard.md) action instead, which the +client performs inside the original gesture. See +[Client actions](../cookbook/client-actions.md). +::: + +## Copying with a client action + + + ## Examples diff --git a/website/docs/services/filepicker.md b/website/docs/services/filepicker.md index 6173d86765..0055188866 100644 --- a/website/docs/services/filepicker.md +++ b/website/docs/services/filepicker.md @@ -27,11 +27,32 @@ In most cases you can use a lambda function for that: ```python ft.Button( - content="Pick files, - on_click=lambda _: file_picker.pick_files(allow_multiple=True) + content="Pick files", + on_click=lambda e: file_picker.pick_files(allow_multiple=True) ) ``` +:::warning[Picking files in a web app] +A browser only opens a file picker while it is handling a click or key press. +Calling `pick_files()` from an `on_click` handler is already too late - the click +has travelled to your Python code and the instruction has travelled back, and by +then the permission is gone. Safari refuses silently, while Chrome and Firefox +allow it, so the same app often works everywhere except on iPhone and iPad. + +Attach a [`PickFiles`](../types/pickfiles.md) action to the control instead, so +the dialog opens inside the original gesture, and handle the selection in +[`on_result`](filepicker.md#flet.FilePicker.on_result). +See [Client actions](../cookbook/client-actions.md). +::: + +## Picking files with a client action + +The picked files stay associated with the `FilePicker`, so +[`upload()`](filepicker.md#flet.FilePicker.upload) works exactly as it does +after `pick_files()`. + + + ### Uploading files To upload one or more files, call [`FilePicker.pick_files()`](filepicker.md#flet.FilePicker.pick_files) diff --git a/website/docs/services/share.md b/website/docs/services/share.md index 852661dfd4..417abfc50c 100644 --- a/website/docs/services/share.md +++ b/website/docs/services/share.md @@ -8,6 +8,20 @@ import {ClassMembers, ClassSummary, CodeExample} from '@site/src/components/croc +:::warning[Sharing from a web app] +Browsers only open the share sheet while they are handling a click or key press, +which the methods below cannot satisfy - by the time the click has reached your +Python code and the instruction has travelled back, the permission is gone. + +Use a [`ShareText`](../types/sharetext.md) action instead, which the client +performs inside the original gesture. See +[Client actions](../cookbook/client-actions.md). +::: + +## Sharing with a client action + + + ## Examples diff --git a/website/docs/services/urllauncher.md b/website/docs/services/urllauncher.md index 1610c2afac..ff1fe36a37 100644 --- a/website/docs/services/urllauncher.md +++ b/website/docs/services/urllauncher.md @@ -8,6 +8,21 @@ import {ClassMembers, ClassSummary, CodeExample} from '@site/src/components/croc +:::note[Opening a new tab in a web app] +A browser treats a new tab that was not opened during a click as a popup and +blocks it, so [`launch_url()`](urllauncher.md#flet.UrlLauncher.launch_url) with +`UrlTarget.BLANK` may not work on the web. + +Set a control's `url` property, or use an +[`OpenUrl`](../types/openurl.md) action - both are performed by the client +inside the original gesture. See +[Client actions](../cookbook/client-actions.md). +::: + +## Opening a URL with a client action + + + ## Examples diff --git a/website/docs/types/copytoclipboard.md b/website/docs/types/copytoclipboard.md new file mode 100644 index 0000000000..19d4015330 --- /dev/null +++ b/website/docs/types/copytoclipboard.md @@ -0,0 +1,7 @@ +--- +title: "CopyToClipboard" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/filepickerresultevent.md b/website/docs/types/filepickerresultevent.md new file mode 100644 index 0000000000..d65f6af145 --- /dev/null +++ b/website/docs/types/filepickerresultevent.md @@ -0,0 +1,7 @@ +--- +title: "FilePickerResultEvent" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/pickfiles.md b/website/docs/types/pickfiles.md new file mode 100644 index 0000000000..e8175f2d89 --- /dev/null +++ b/website/docs/types/pickfiles.md @@ -0,0 +1,7 @@ +--- +title: "PickFiles" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/sharetext.md b/website/docs/types/sharetext.md new file mode 100644 index 0000000000..077bcf4fae --- /dev/null +++ b/website/docs/types/sharetext.md @@ -0,0 +1,7 @@ +--- +title: "ShareText" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/sidebars.yml b/website/sidebars.yml index fd60abeb80..8357dc9178 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -667,7 +667,10 @@ docs: TooltipTheme: types/tooltiptheme.md - types/browserconfiguration.md - types/clientaction.md + - types/copytoclipboard.md - types/openurl.md + - types/pickfiles.md + - types/sharetext.md - types/tooltip.md - types/url.md - types/webviewconfiguration.md @@ -836,6 +839,7 @@ docs: - types/dragwillacceptevent.md - types/event.md - types/expansionpanellistchangeevent.md + - types/filepickerresultevent.md - types/filepickeruploadevent.md - types/gyroscopereadingevent.md - types/hoverevent.md From 097a5d2982c9ecd91162b54cd38d908b2eb6fa70 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 28 Aug 2026 15:27:33 -0700 Subject: [PATCH 04/10] refactor: rename protocol ClientAction enum to MessageAction The Dart side already calls this enum MessageAction - protocol/message.dart - and the Python docstring said so, which left the two languages naming the same wire format differently. It also covers server-to-client frames (PATCH_CONTROL, SESSION_CRASHED) as much as client-to-server ones, so 'client action' was never quite right. The name is now needed elsewhere: ft.ClientAction is the public base class for actions a control performs on the client. Both are unambiguous in their own module, but two classes with one name is a trap for whoever edits them next. Internal throughout - the enum has never been exported from flet. --- .../flet-web/src/flet_web/fastapi/flet_app.py | 16 ++++++++-------- .../flet/messaging/flet_dart_bridge_server.py | 14 +++++++------- .../src/flet/messaging/flet_socket_server.py | 14 +++++++------- .../packages/flet/src/flet/messaging/protocol.py | 9 +++++---- .../src/flet/messaging/pyodide_connection.py | 14 +++++++------- .../packages/flet/src/flet/messaging/session.py | 8 ++++---- .../tests/test_session_disconnect_buffering.py | 6 +++--- .../packages/flet/tests/test_use_dialog.py | 4 ++-- 8 files changed, 43 insertions(+), 42 deletions(-) diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py index 1bbde15bde..094ac43886 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py @@ -18,10 +18,10 @@ from flet.controls.exceptions import FletPageDisconnectedException from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, ClientMessage, ControlEventBody, InvokeMethodResponseBody, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -259,7 +259,7 @@ async def __receive_loop(self): async def __on_message(self, data: Any): """ Handle one decoded client message and dispatch - by `ClientAction`. + by `MessageAction`. Args: data: Decoded message payload from msgpack transport. @@ -268,11 +268,11 @@ async def __on_message(self, data: Any): RuntimeError: If message action is unknown. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug(f"_on_message: {action} {body}") task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) new_session = False @@ -336,7 +336,7 @@ async def __on_message(self, data: Any): # register response self.send_message( ClientMessage( - ClientAction.REGISTER_CLIENT, + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.__session.id, page_patch=self.__session.get_page_patch() @@ -379,17 +379,17 @@ async def __on_message(self, data: Any): } ) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.__session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.__session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.__session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error diff --git a/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py b/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py index a232a06efe..20e6af5cd1 100644 --- a/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py +++ b/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py @@ -35,10 +35,10 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, ClientMessage, ControlEventBody, InvokeMethodResponseBody, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -154,11 +154,11 @@ async def __on_message(self, data: Any): Duplicated here to keep the two transports decoupled; refactor into a shared base once both have stabilised. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug("_on_message: %s %s", action, body) task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) # create new session @@ -181,7 +181,7 @@ async def __on_message(self, data: Any): # register response self.send_message( ClientMessage( - ClientAction.REGISTER_CLIENT, + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, page_patch=self.session.get_page_patch(), @@ -195,17 +195,17 @@ async def __on_message(self, data: Any): elif self.__on_session_created is not None: task = asyncio.create_task(self.__on_session_created(self.session)) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error diff --git a/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py b/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py index ee7935034f..108748679d 100644 --- a/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py +++ b/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py @@ -15,10 +15,10 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, ClientMessage, ControlEventBody, InvokeMethodResponseBody, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -357,11 +357,11 @@ async def __on_message(self, data: Any): Raises: RuntimeError: If the action code is unknown. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug("_on_message: %s %s", action, body) task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) # create new session @@ -384,7 +384,7 @@ async def __on_message(self, data: Any): # register response self.send_message( ClientMessage( - ClientAction.REGISTER_CLIENT, + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, page_patch=self.session.get_page_patch(), @@ -398,17 +398,17 @@ async def __on_message(self, data: Any): elif self.__on_session_created is not None: task = asyncio.create_task(self.__on_session_created(self.session)) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error diff --git a/sdk/python/packages/flet/src/flet/messaging/protocol.py b/sdk/python/packages/flet/src/flet/messaging/protocol.py index 9ddde6e431..54042d2448 100644 --- a/sdk/python/packages/flet/src/flet/messaging/protocol.py +++ b/sdk/python/packages/flet/src/flet/messaging/protocol.py @@ -222,12 +222,13 @@ def decode_ext_from_msgpack(code, data): return msgpack.ExtType(code, data) -class ClientAction(Enum): +class MessageAction(Enum): """ Wire-level action codes exchanged between Python and Dart clients. - Integer values must stay in sync with Dart `MessageAction` values because - protocol frames are encoded as `[action_code, body]`. + Integer values must stay in sync with the Dart enum of the same name + (`protocol/message.dart`) because protocol frames are encoded as + `[action_code, body]`. """ REGISTER_CLIENT = 1 @@ -276,7 +277,7 @@ class ClientMessage: Messages are serialized as a two-item sequence: `[action_code, body]`. """ - action: ClientAction + action: MessageAction """ Action discriminator for this message. """ diff --git a/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py b/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py index 5de5bfa55b..2338fa1c5c 100644 --- a/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py +++ b/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py @@ -11,10 +11,10 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, ClientMessage, ControlEventBody, InvokeMethodResponseBody, + MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, UpdateControlPropsBody, @@ -127,11 +127,11 @@ async def __on_message(self, data: Any): Raises: RuntimeError: If the action type is unknown. """ - action = ClientAction(data[0]) + action = MessageAction(data[0]) body = data[1] transport_log.debug("_on_message: %s %s", action, body) task = None - if action == ClientAction.REGISTER_CLIENT: + if action == MessageAction.REGISTER_CLIENT: req = RegisterClientRequestBody(**body) # create new session @@ -153,7 +153,7 @@ async def __on_message(self, data: Any): # register response self.send_message( ClientMessage( - ClientAction.REGISTER_CLIENT, + MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, page_patch=self.session.get_page_patch(), @@ -168,17 +168,17 @@ async def __on_message(self, data: Any): elif register_error: self.session.error(register_error) - elif action == ClientAction.CONTROL_EVENT: + elif action == MessageAction.CONTROL_EVENT: req = ControlEventBody(**body) task = asyncio.create_task( self.session.dispatch_event(req.target, req.name, req.data) ) - elif action == ClientAction.UPDATE_CONTROL_PROPS: + elif action == MessageAction.UPDATE_CONTROL_PROPS: req = UpdateControlPropsBody(**body) self.session.apply_patch(req.id, req.props) - elif action == ClientAction.INVOKE_METHOD: + elif action == MessageAction.INVOKE_METHOD: req = InvokeMethodResponseBody(**body) self.session.handle_invoke_method_results( req.control_id, req.call_id, req.result, req.error diff --git a/sdk/python/packages/flet/src/flet/messaging/session.py b/sdk/python/packages/flet/src/flet/messaging/session.py index c76093081b..00da8dd1d7 100644 --- a/sdk/python/packages/flet/src/flet/messaging/session.py +++ b/sdk/python/packages/flet/src/flet/messaging/session.py @@ -13,9 +13,9 @@ from flet.controls.page import Page from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientAction, ClientMessage, InvokeMethodRequestBody, + MessageAction, PatchControlBody, SessionCrashedBody, ) @@ -276,7 +276,7 @@ def patch_control( if len(patch) > 1: self.__send_message( ClientMessage( - ClientAction.PATCH_CONTROL, + MessageAction.PATCH_CONTROL, PatchControlBody(parent._i if parent else control._i, patch), ) ) @@ -486,7 +486,7 @@ async def invoke_method( # call method self.__send_message( ClientMessage( - ClientAction.INVOKE_METHOD, + MessageAction.INVOKE_METHOD, InvokeMethodRequestBody( control_id=control_id, call_id=call_id, name=method_name, args=args ), @@ -588,7 +588,7 @@ def error(self, message: str): message: Error message to report. """ self.__send_message( - ClientMessage(ClientAction.SESSION_CRASHED, SessionCrashedBody(message)) + ClientMessage(MessageAction.SESSION_CRASHED, SessionCrashedBody(message)) ) def __send_message(self, message: ClientMessage): diff --git a/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py b/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py index ba561a41fb..0027f52d2c 100644 --- a/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py +++ b/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py @@ -3,7 +3,7 @@ from flet.components.component import Component from flet.components.hooks.use_effect import EffectHook from flet.messaging.connection import Connection -from flet.messaging.protocol import ClientAction, ClientMessage, SessionCrashedBody +from flet.messaging.protocol import ClientMessage, MessageAction, SessionCrashedBody from flet.messaging.session import Session from flet.pubsub.pubsub_hub import PubSubHub @@ -25,7 +25,7 @@ def test_disconnected_session_drops_incremental_messages(): session._Session__expires_at = datetime.now(timezone.utc) session._Session__send_message( # type: ignore[attr-defined] - ClientMessage(ClientAction.SESSION_CRASHED, SessionCrashedBody("x")) + ClientMessage(MessageAction.SESSION_CRASHED, SessionCrashedBody("x")) ) assert session._Session__send_buffer == [] @@ -55,7 +55,7 @@ def test_attach_connection_restores_state_and_flushes_buffer(): session = Session(initial_conn) buffered_message = ClientMessage( - ClientAction.SESSION_CRASHED, SessionCrashedBody("buffered") + MessageAction.SESSION_CRASHED, SessionCrashedBody("buffered") ) session._Session__conn = None session._Session__expires_at = datetime.now(timezone.utc) diff --git a/sdk/python/packages/flet/tests/test_use_dialog.py b/sdk/python/packages/flet/tests/test_use_dialog.py index 15d5bea48c..6139576cc0 100644 --- a/sdk/python/packages/flet/tests/test_use_dialog.py +++ b/sdk/python/packages/flet/tests/test_use_dialog.py @@ -10,7 +10,7 @@ from flet.controls.context import _context_page from flet.controls.control_event import ControlEvent from flet.messaging.connection import Connection -from flet.messaging.protocol import ClientAction, configure_encode_object_for_msgpack +from flet.messaging.protocol import MessageAction, configure_encode_object_for_msgpack from flet.messaging.session import Session from flet.pubsub.pubsub_hub import PubSubHub @@ -70,7 +70,7 @@ async def flush_async(turns: int = 5) -> None: def crash_messages(conn: _RecordingConnection): - return [m for m in conn.messages if m.action == ClientAction.SESSION_CRASHED] + return [m for m in conn.messages if m.action == MessageAction.SESSION_CRASHED] def find_filled_button(session: Session, label: str) -> ft.FilledButton: From 0986ab0c33aa144ca03805f06e94b8e6510e05f0 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 28 Aug 2026 15:33:07 -0700 Subject: [PATCH 05/10] refactor: rename ClientMessage to Message Completes the alignment started by the MessageAction rename. Dart calls this frame Message (protocol/message.dart), so leaving the Python class as ClientMessage left the pair half-matched - a reader who learns MessageAction maps to MessageAction would reasonably assume ClientMessage maps to ClientMessage, and be wrong. 'Client' was inaccurate for the same reason it was on the enum: the frame carries PATCH_CONTROL and SESSION_CRASHED, which travel to the client, as often as it carries events coming from one. The 'body' field keeps its name. Dart calls it payload, but body anchors PatchControlBody, InvokeMethodRequestBody and SessionCrashedBody, so renaming it would either cascade through all of them or trade one mismatch for a worse one. The class docstring used both words for that single field; it now says body throughout. Internal - neither name has ever been exported from flet. --- .../flet-web/src/flet_web/fastapi/flet_app.py | 6 +++--- .../packages/flet/src/flet/messaging/connection.py | 4 ++-- .../src/flet/messaging/flet_dart_bridge_server.py | 6 +++--- .../flet/src/flet/messaging/flet_socket_server.py | 6 +++--- .../packages/flet/src/flet/messaging/protocol.py | 7 ++++--- .../flet/src/flet/messaging/pyodide_connection.py | 6 +++--- .../packages/flet/src/flet/messaging/session.py | 12 ++++++------ .../flet/tests/test_session_disconnect_buffering.py | 6 +++--- 8 files changed, 27 insertions(+), 26 deletions(-) diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py index 094ac43886..d81b806a67 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py @@ -18,9 +18,9 @@ from flet.controls.exceptions import FletPageDisconnectedException from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, @@ -335,7 +335,7 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( + Message( MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.__session.id, @@ -403,7 +403,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Serialize and enqueue a server message for transport to the client. diff --git a/sdk/python/packages/flet/src/flet/messaging/connection.py b/sdk/python/packages/flet/src/flet/messaging/connection.py index 7ccbae4b7c..fd7a1e7584 100644 --- a/sdk/python/packages/flet/src/flet/messaging/connection.py +++ b/sdk/python/packages/flet/src/flet/messaging/connection.py @@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional -from flet.messaging.protocol import ClientMessage +from flet.messaging.protocol import Message from flet.pubsub.pubsub_hub import PubSubHub logger = logging.getLogger("flet") @@ -87,7 +87,7 @@ def pubsubhub(self) -> PubSubHub: def pubsubhub(self, value: PubSubHub): self.__pubsubhub = value - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Sends a message to the connected Flet client. diff --git a/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py b/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py index 20e6af5cd1..2499de667b 100644 --- a/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py +++ b/sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py @@ -35,9 +35,9 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, @@ -180,7 +180,7 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( + Message( MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, @@ -218,7 +218,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Encodes a protocol message and posts it to the Dart side via `dart_bridge.send_bytes`. Non-blocking; ordering is preserved by the diff --git a/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py b/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py index 108748679d..e6e28325ff 100644 --- a/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py +++ b/sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py @@ -15,9 +15,9 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, @@ -383,7 +383,7 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( + Message( MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, @@ -422,7 +422,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Encodes and queues an outbound message for the active connection. diff --git a/sdk/python/packages/flet/src/flet/messaging/protocol.py b/sdk/python/packages/flet/src/flet/messaging/protocol.py index 54042d2448..eb56360572 100644 --- a/sdk/python/packages/flet/src/flet/messaging/protocol.py +++ b/sdk/python/packages/flet/src/flet/messaging/protocol.py @@ -270,11 +270,12 @@ class MessageAction(Enum): @dataclass -class ClientMessage: +class Message: """ - Top-level protocol frame with action and payload. + Top-level protocol frame, mirroring the Dart class of the same name + (`protocol/message.dart`). - Messages are serialized as a two-item sequence: `[action_code, body]`. + Serialized as a two-item sequence: `[action_code, body]`. """ action: MessageAction diff --git a/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py b/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py index 2338fa1c5c..855ead0ea4 100644 --- a/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py +++ b/sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py @@ -11,9 +11,9 @@ from flet.controls.base_control import BaseControl from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientMessage, ControlEventBody, InvokeMethodResponseBody, + Message, MessageAction, RegisterClientRequestBody, RegisterClientResponseBody, @@ -152,7 +152,7 @@ async def __on_message(self, data: Any): # register response self.send_message( - ClientMessage( + Message( MessageAction.REGISTER_CLIENT, RegisterClientResponseBody( session_id=self.session.id, @@ -192,7 +192,7 @@ async def __on_message(self, data: Any): self.__running_tasks.add(task) task.add_done_callback(self.__running_tasks.discard) - def send_message(self, message: ClientMessage): + def send_message(self, message: Message): """ Serializes and sends an outbound protocol message to JavaScript. diff --git a/sdk/python/packages/flet/src/flet/messaging/session.py b/sdk/python/packages/flet/src/flet/messaging/session.py index 00da8dd1d7..3ad3027486 100644 --- a/sdk/python/packages/flet/src/flet/messaging/session.py +++ b/sdk/python/packages/flet/src/flet/messaging/session.py @@ -13,8 +13,8 @@ from flet.controls.page import Page from flet.messaging.connection import Connection from flet.messaging.protocol import ( - ClientMessage, InvokeMethodRequestBody, + Message, MessageAction, PatchControlBody, SessionCrashedBody, @@ -46,7 +46,7 @@ class Session: def __init__(self, conn: Connection): self.__conn = conn - self.__send_buffer: list[ClientMessage] = [] + self.__send_buffer: list[Message] = [] self.__id = random_string(16) self.__expires_at = None self.__index: weakref.WeakValueDictionary[int, BaseControl] = ( @@ -275,7 +275,7 @@ def patch_control( if len(patch) > 1: self.__send_message( - ClientMessage( + Message( MessageAction.PATCH_CONTROL, PatchControlBody(parent._i if parent else control._i, patch), ) @@ -485,7 +485,7 @@ async def invoke_method( # call method self.__send_message( - ClientMessage( + Message( MessageAction.INVOKE_METHOD, InvokeMethodRequestBody( control_id=control_id, call_id=call_id, name=method_name, args=args @@ -588,10 +588,10 @@ def error(self, message: str): message: Error message to report. """ self.__send_message( - ClientMessage(MessageAction.SESSION_CRASHED, SessionCrashedBody(message)) + Message(MessageAction.SESSION_CRASHED, SessionCrashedBody(message)) ) - def __send_message(self, message: ClientMessage): + def __send_message(self, message: Message): """ Sends a message immediately or buffers it until reconnection. diff --git a/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py b/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py index 0027f52d2c..2ef7110308 100644 --- a/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py +++ b/sdk/python/packages/flet/tests/test_session_disconnect_buffering.py @@ -3,7 +3,7 @@ from flet.components.component import Component from flet.components.hooks.use_effect import EffectHook from flet.messaging.connection import Connection -from flet.messaging.protocol import ClientMessage, MessageAction, SessionCrashedBody +from flet.messaging.protocol import Message, MessageAction, SessionCrashedBody from flet.messaging.session import Session from flet.pubsub.pubsub_hub import PubSubHub @@ -25,7 +25,7 @@ def test_disconnected_session_drops_incremental_messages(): session._Session__expires_at = datetime.now(timezone.utc) session._Session__send_message( # type: ignore[attr-defined] - ClientMessage(MessageAction.SESSION_CRASHED, SessionCrashedBody("x")) + Message(MessageAction.SESSION_CRASHED, SessionCrashedBody("x")) ) assert session._Session__send_buffer == [] @@ -54,7 +54,7 @@ def test_attach_connection_restores_state_and_flushes_buffer(): initial_conn.pubsubhub = PubSubHub() session = Session(initial_conn) - buffered_message = ClientMessage( + buffered_message = Message( MessageAction.SESSION_CRASHED, SessionCrashedBody("buffered") ) session._Session__conn = None From 8223593406e624b97627557dfe65536569ad7c8e Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 28 Aug 2026 15:38:07 -0700 Subject: [PATCH 06/10] fix: UrlTarget.BLANK opening one shared tab instead of a new one UrlTarget.BLANK serialized as "blank" while SELF, PARENT and TOP all carry the leading underscore the HTML spec defines. window.open() treats an unrecognized target as an ordinary window *name*, so the first BLANK link opened a tab called "blank" and every later one reused it - close enough to working that it went unnoticed. The same mismatch meant openWebBrowser()'s check for "_blank" never matched, so BLANK never upgraded to LaunchMode.externalApplication on non-web platforms. Introduced in #5382; not in any stable release, so the enum value is simply corrected rather than accepting both spellings. --- CHANGELOG.md | 2 ++ sdk/python/packages/flet/src/flet/controls/types.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f639d83a1..206321c823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ ### Bug fixes +* Fix `UrlTarget.BLANK` not actually opening a new tab per link on the web. Its value was `"blank"` while `SELF`, `PARENT` and `TOP` all carry the leading underscore the HTML spec defines, so the value reached `window.open()` as an ordinary window *name* rather than the reserved `_blank` keyword: the first such link opened a tab called `blank` and every later one reused that same tab instead of opening its own. The `LaunchMode.externalApplication` upgrade that `openWebBrowser()` applies to `_blank` never fired either, so on non-web platforms `BLANK` did not force an external browser as intended. The enum value is now `"_blank"` by @FeodorFitsner. + * Fix `FilePicker.pick_files()` never opening a dialog in a web app on iOS, and `Clipboard.set()`, `Clipboard.set_image()` and the `Share` methods doing nothing there either. A browser opens a file picker, writes to the clipboard or shows a share sheet only while it is handling the user's click or key press, and a service method called from an event handler misses that window entirely: the click travels to Python, the handler runs, and the instruction travels back long after the permission has lapsed. WebKit enforces this and reports nothing, while Chrome and Firefox allow the same calls, so an app worked on Android and on the desktop and silently did nothing on an iPhone or iPad - which made a browser rule look like a Flet bug, and made `FilePicker` in particular look broken since `save_file()` kept working (it clicks a download link, which is not gated). These operations are now available as client actions - `ft.PickFiles`, `ft.CopyToClipboard`, `ft.ShareText`, `ft.OpenUrl` - assigned to a control's `action` property and performed by the client inside the original gesture. `pick_files()` also no longer hangs for its full one-hour timeout when a browser has already reported it will not open the dialog: it raises straight away, pointing at `ft.PickFiles` ([#3710](https://github.com/flet-dev/flet/issues/3710)) 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. diff --git a/sdk/python/packages/flet/src/flet/controls/types.py b/sdk/python/packages/flet/src/flet/controls/types.py index a745290e6f..72384789b9 100644 --- a/sdk/python/packages/flet/src/flet/controls/types.py +++ b/sdk/python/packages/flet/src/flet/controls/types.py @@ -124,7 +124,7 @@ class UrlTarget(Enum): Specifies where to open a URL. """ - BLANK = "blank" + BLANK = "_blank" """ Opens the URL in a new browser tab or window. """ From e3bfe84e5492bd914b256c7e38a66b6efebbf578 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 29 Aug 2026 11:16:42 -0700 Subject: [PATCH 07/10] refactor: move the action property into an ActionControl base class Ten controls carried an identical action field and a nine-line docstring. Follows the AdaptiveControl pattern exactly: a @control(kw_only=True) base contributing one field to whichever controls opt in. kw_only matters here - without it the inherited field would take the first positional slot and ft.Button("Open") would stop binding to content. TextSpan inherits ActionControl directly rather than listing it after Control, which ActionControl already derives from. Net -111 lines across the ten controls, and one place to edit when the wording changes. --- sdk/python/packages/flet/src/flet/__init__.py | 3 ++ .../flet/src/flet/controls/action_control.py | 38 +++++++++++++++++++ .../flet/src/flet/controls/core/text_span.py | 16 +------- .../controls/cupertino/cupertino_button.py | 15 +------- .../controls/cupertino/cupertino_list_tile.py | 15 +------- .../flet/src/flet/controls/material/button.py | 15 +------- .../src/flet/controls/material/container.py | 15 +------- .../material/floating_action_button.py | 15 +------- .../src/flet/controls/material/icon_button.py | 15 +------- .../src/flet/controls/material/list_tile.py | 15 +------- .../flet/controls/material/outlined_button.py | 15 +------- .../src/flet/controls/material/text_button.py | 15 +------- website/docs/controls/actioncontrol.md | 8 ++++ website/sidebars.yml | 1 + 14 files changed, 70 insertions(+), 131 deletions(-) create mode 100644 sdk/python/packages/flet/src/flet/controls/action_control.py create mode 100644 website/docs/controls/actioncontrol.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 7e04ba290f..f382de9d72 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -54,6 +54,7 @@ margin, padding, ) + from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.alignment import ( Alignment, @@ -735,6 +736,7 @@ __all__ = [ "Accelerometer", "AccelerometerReadingEvent", + "ActionControl", "AdaptiveControl", "AlertDialog", "Alignment", @@ -1271,6 +1273,7 @@ _LAZY = { "Accelerometer": "flet.controls.services.accelerometer", "AccelerometerReadingEvent": "flet.controls.services.accelerometer", + "ActionControl": "flet.controls.action_control", "AdaptiveControl": "flet.controls.adaptive_control", "AlertDialog": "flet.controls.material.alert_dialog", "Alignment": "flet.controls.alignment", diff --git a/sdk/python/packages/flet/src/flet/controls/action_control.py b/sdk/python/packages/flet/src/flet/controls/action_control.py new file mode 100644 index 0000000000..a44473610b --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/action_control.py @@ -0,0 +1,38 @@ +from typing import Optional, Union + +from flet.controls.base_control import control +from flet.controls.client_action import ClientAction +from flet.controls.control import Control + +__all__ = ["ActionControl"] + + +@control(kw_only=True) +class ActionControl(Control): + """ + Base class for controls that can perform :class:`~flet.ClientAction` work when + they are activated. + + Browsers only allow a page to open a file picker, write to the clipboard, show + a share sheet or open a new tab while they are handling the user's click or key + press. Sending that click to your Python code and acting on the reply takes + longer than the permission lasts, so those operations are silently ignored on + iOS Safari while Android and desktop browsers let them through. Controls + inheriting from this class accept an :attr:`action`, which the client performs + inside the original gesture instead. + + Extension developers can inherit from this class to give their own controls the + same capability; the client runs whatever actions the control declares before + the control's own click event is dispatched. + """ + + action: Optional[Union[ClientAction, list[ClientAction]]] = None + """ + Action(s) performed by the client when this control is activated, without a + round trip to your Python code. + + Use this for operations a browser only permits while it is handling the + user's click, such as opening a file picker, writing to the clipboard, + showing a share sheet or opening a new tab. + See :class:`~flet.ClientAction`. + """ diff --git a/sdk/python/packages/flet/src/flet/controls/core/text_span.py b/sdk/python/packages/flet/src/flet/controls/core/text_span.py index a32b150fab..29bf2191f1 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/text_span.py +++ b/sdk/python/packages/flet/src/flet/controls/core/text_span.py @@ -1,8 +1,7 @@ from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.base_control import control -from flet.controls.client_action import ClientAction -from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.text_style import TextStyle from flet.controls.types import Url @@ -12,7 +11,7 @@ @control("TextSpan") -class TextSpan(Control): +class TextSpan(ActionControl): """ A text span. @@ -45,17 +44,6 @@ class TextSpan(Control): the `text` takes precedence. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this span is tapped, without a round - trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py index a23d286321..eeb2e21faa 100644 --- a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py +++ b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py @@ -2,10 +2,10 @@ from enum import Enum from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.alignment import Alignment from flet.controls.base_control import control from flet.controls.border_radius import BorderRadius, BorderRadiusValue -from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.geometry import Size from flet.controls.layout_control import LayoutControl @@ -47,7 +47,7 @@ class CupertinoButtonSize(Enum): @control("CupertinoButton") -class CupertinoButton(LayoutControl): +class CupertinoButton(LayoutControl, ActionControl): """ An iOS-style button. @@ -132,17 +132,6 @@ class CupertinoButton(LayoutControl): The radius of the button's corners when it has a background color. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py index 474653d750..cbbabc21fd 100644 --- a/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py +++ b/sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py @@ -1,7 +1,7 @@ from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.base_control import control -from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.padding import PaddingValue @@ -18,7 +18,7 @@ @control("CupertinoListTile") -class CupertinoListTile(LayoutControl): +class CupertinoListTile(LayoutControl, ActionControl): """ An iOS-style list tile. @@ -86,17 +86,6 @@ class CupertinoListTile(LayoutControl): :attr:`trailing` controls. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/button.py b/sdk/python/packages/flet/src/flet/controls/material/button.py index 0f507716e4..756c7c5889 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/button.py @@ -1,10 +1,10 @@ from dataclasses import field from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle -from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.icon_data import IconData @@ -23,7 +23,7 @@ @control("Button") -class Button(LayoutControl, AdaptiveControl): +class Button(LayoutControl, AdaptiveControl, ActionControl): """ A material button. @@ -97,17 +97,6 @@ class Button(LayoutControl, AdaptiveControl): The button's clip behavior. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when the button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/container.py b/sdk/python/packages/flet/src/flet/controls/material/container.py index fc99e8dcfc..9fded128a4 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/container.py +++ b/sdk/python/packages/flet/src/flet/controls/material/container.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.alignment import Alignment from flet.controls.animation import AnimationValue @@ -14,7 +15,6 @@ ColorFilter, DecorationImage, ) -from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler, EventHandler from flet.controls.events import TapEvent @@ -36,7 +36,7 @@ @control("Container") -class Container(LayoutControl, AdaptiveControl): +class Container(LayoutControl, AdaptiveControl, ActionControl): """ Allows to decorate a control with background color and border and position it with \ padding, margin and alignment. @@ -175,17 +175,6 @@ class Container(LayoutControl, AdaptiveControl): The shadow(s) below this container. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this container is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py b/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py index 1d37c17418..028447f463 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py @@ -1,8 +1,8 @@ from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.base_control import control from flet.controls.buttons import OutlinedBorder -from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.icon_data import IconData @@ -22,7 +22,7 @@ @control("FloatingActionButton") -class FloatingActionButton(LayoutControl): +class FloatingActionButton(LayoutControl, ActionControl): """ A floating action button is a circular icon button that hovers over content to \ promote a primary action in the application. Floating action button is usually set \ @@ -172,17 +172,6 @@ class FloatingActionButton(LayoutControl): long-press will produce a short vibration. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/icon_button.py b/sdk/python/packages/flet/src/flet/controls/material/icon_button.py index 19ebe60e6b..e4da50426f 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/icon_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/icon_button.py @@ -1,12 +1,12 @@ from dataclasses import field from typing import Annotated, Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.alignment import Alignment from flet.controls.base_control import control from flet.controls.box import BoxConstraints from flet.controls.buttons import ButtonStyle -from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.padding import PaddingValue @@ -29,7 +29,7 @@ @control("IconButton") -class IconButton(LayoutControl, AdaptiveControl): +class IconButton(LayoutControl, AdaptiveControl, ActionControl): """ An icon button is a round button with an icon in the middle that reacts to touches \ by filling with color (ink). @@ -171,17 +171,6 @@ class IconButton(LayoutControl, AdaptiveControl): long-press will produce a short vibration. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/list_tile.py b/sdk/python/packages/flet/src/flet/controls/material/list_tile.py index 9550ced8d6..5bd3973c70 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/list_tile.py +++ b/sdk/python/packages/flet/src/flet/controls/material/list_tile.py @@ -1,10 +1,10 @@ from enum import Enum from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import OutlinedBorder -from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.padding import PaddingValue @@ -91,7 +91,7 @@ class ListTileStyle(Enum): @control("ListTile") -class ListTile(LayoutControl, AdaptiveControl): +class ListTile(LayoutControl, AdaptiveControl, ActionControl): """ A single fixed-height row that typically contains some text as well as a leading \ or trailing icon. @@ -246,17 +246,6 @@ class ListTile(LayoutControl, AdaptiveControl): The minimum padding on the top and bottom of the `title` and `subtitle` controls. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py b/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py index ad1478ad2d..d2bfebff09 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/outlined_button.py @@ -1,9 +1,9 @@ from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle -from flet.controls.client_action import ClientAction from flet.controls.control import Control from flet.controls.control_event import ControlEventHandler from flet.controls.icon_data import IconData @@ -21,7 +21,7 @@ @control("OutlinedButton") -class OutlinedButton(LayoutControl, AdaptiveControl): +class OutlinedButton(LayoutControl, AdaptiveControl, ActionControl): """ Outlined buttons are medium-emphasis buttons. They contain actions that are \ important, but aren't the primary action in an app. Outlined buttons pair well \ @@ -74,17 +74,6 @@ class OutlinedButton(LayoutControl, AdaptiveControl): The content will be clipped (or not) according to this option. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/sdk/python/packages/flet/src/flet/controls/material/text_button.py b/sdk/python/packages/flet/src/flet/controls/material/text_button.py index 9a567252be..c13739e02c 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/text_button.py +++ b/sdk/python/packages/flet/src/flet/controls/material/text_button.py @@ -1,9 +1,9 @@ from typing import Optional, Union +from flet.controls.action_control import ActionControl from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import control from flet.controls.buttons import ButtonStyle -from flet.controls.client_action import ClientAction from flet.controls.control_event import ControlEventHandler from flet.controls.layout_control import LayoutControl from flet.controls.types import ( @@ -18,7 +18,7 @@ @control("TextButton") -class TextButton(LayoutControl, AdaptiveControl): +class TextButton(LayoutControl, AdaptiveControl, ActionControl): """ Text buttons are used for the lowest priority actions, especially when presenting \ multiple options. Text buttons can be placed on a variety of backgrounds. Until \ @@ -61,17 +61,6 @@ class TextButton(LayoutControl, AdaptiveControl): added to the page will get focus. """ - action: Optional[Union[ClientAction, list[ClientAction]]] = None - """ - Action(s) performed by the client when this control is activated, without a - round trip to your Python code. - - Use this for operations a browser only permits while it is handling the - user's click, such as opening a file picker, writing to the clipboard, - showing a share sheet or opening a new tab. - See :class:`~flet.ClientAction`. - """ - url: Optional[Union[str, Url]] = None """ The URL to open when this button is clicked. diff --git a/website/docs/controls/actioncontrol.md b/website/docs/controls/actioncontrol.md new file mode 100644 index 0000000000..9c69689049 --- /dev/null +++ b/website/docs/controls/actioncontrol.md @@ -0,0 +1,8 @@ +--- +class_name: "flet.ActionControl" +title: "ActionControl" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/sidebars.yml b/website/sidebars.yml index 8357dc9178..cf830357d6 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -397,6 +397,7 @@ docs: - types/testing/finder.md - types/testing/disposalmode.md Base Controls: + - controls/actioncontrol.md - controls/adaptivecontrol.md - controls/basecontrol.md - controls/basepage.md From 00a8cb6018ca7d41b99e51aa53d70ffe53347845 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 29 Aug 2026 12:15:51 -0700 Subject: [PATCH 08/10] fix: client action service cache made the page unserializable _shared_service() cached the services actions target in page._internals, keyed by service class. _internals is not skipped by the msgpack encoder - it is how Button ships its resolved style to the client - so the cache went on the wire and the encoder tried to read _i off a class object: AttributeError: type object 'Clipboard' has no attribute '_i' Any page holding a CopyToClipboard, OpenUrl or ShareText action failed to render at all. The cache is now a module-level WeakKeyDictionary keyed by page, so it never touches control state, and the page is released normally. Adds tests/test_client_actions.py, which covers the serialization path through page.add() rather than the action object alone - the earlier check stubbed out _shared_service and so exercised none of this. --- .gitignore | 4 + .../flet/src/flet/controls/client_action.py | 14 ++- .../flet/tests/test_client_actions.py | 111 ++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 sdk/python/packages/flet/tests/test_client_actions.py diff --git a/.gitignore b/.gitignore index 6795b1796a..2799e26867 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ client/devtools_options.yaml # Actual screenshots written by failed golden comparisons in integration tests *_actual.png + +# Files uploaded while running the file_picker examples, which pass +# upload_dir="examples" to ft.run() and so write under the example's own folder +sdk/python/examples/**/examples/ diff --git a/sdk/python/packages/flet/src/flet/controls/client_action.py b/sdk/python/packages/flet/src/flet/controls/client_action.py index 22d585a13c..5a2163b536 100644 --- a/sdk/python/packages/flet/src/flet/controls/client_action.py +++ b/sdk/python/packages/flet/src/flet/controls/client_action.py @@ -1,5 +1,6 @@ from dataclasses import MISSING, dataclass, field from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union +from weakref import WeakKeyDictionary from flet.controls.context import context from flet.controls.services.service import Service @@ -19,6 +20,17 @@ S = TypeVar("S", bound=Service) +_shared_services: "WeakKeyDictionary[Any, dict[type, Service]]" = WeakKeyDictionary() +""" +Per-page cache of the services client actions target, keyed weakly so it does +not keep a finished page alive. + +Deliberately not stored on the page itself: `BaseControl._internals` is sent to +the client (it is how `Button` ships its resolved style), and a cache keyed by +class would put a class object on the wire. +""" + + def _shared_service(service_type: type[S]) -> S: """ Returns the page's single instance of `service_type`, creating it on first use. @@ -29,7 +41,7 @@ def _shared_service(service_type: type[S]) -> S: state. """ page = context.page - services = page._internals.setdefault("client_action_services", {}) + services = _shared_services.setdefault(page, {}) service = services.get(service_type) if service is None: service = service_type() diff --git a/sdk/python/packages/flet/tests/test_client_actions.py b/sdk/python/packages/flet/tests/test_client_actions.py new file mode 100644 index 0000000000..5cc4447f56 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_client_actions.py @@ -0,0 +1,111 @@ +import weakref + +import msgpack +import pytest + +import flet as ft +from flet.controls.base_control import BaseControl +from flet.controls.context import _context_page +from flet.messaging.protocol import configure_encode_object_for_msgpack + + +class FakeSession: + def __init__(self): + self.index: dict[int, object] = {} + + def patch_control(self, control, **kwargs): + pass + + def schedule_update(self, control): + pass + + async def after_event(self, control): + pass + + +@pytest.fixture +def page(): + # Page holds its session weakly, so the local reference has to outlive the + # test or `page.session` raises "An attempt to fetch destroyed session". + session = FakeSession() + page = ft.Page(sess=session) + page._dialogs._parent = weakref.ref(page) + token = _context_page.set(page) + yield page + _context_page.reset(token) + del session + + +def pack(obj): + return msgpack.unpackb( + msgpack.packb(obj, default=configure_encode_object_for_msgpack(BaseControl)), + strict_map_key=False, + ) + + +def test_page_is_serializable_after_creating_an_action(page): + """ + The services an action targets must not be cached anywhere that reaches the + wire. Stashing them in `page._internals` - which *is* serialized - made the + whole page unpackable with "type object 'Clipboard' has no attribute '_i'". + """ + page.add(ft.Button("Copy", action=ft.CopyToClipboard("hello"))) + pack(page) + + +def test_action_serializes_as_a_service_call(page): + action = ft.CopyToClipboard("hello") + assert pack(action) == { + "service_id": action._service._i, + "method": "set", + "args": {"data": "hello"}, + } + + +def test_actions_of_one_kind_share_a_single_service(page): + first = ft.CopyToClipboard("a") + second = ft.CopyToClipboard("b") + assert first.service_id == second.service_id + + +def test_actions_of_different_kinds_target_different_services(page): + assert ( + ft.CopyToClipboard("a").service_id != ft.OpenUrl("https://flet.dev").service_id + ) + + +def test_open_url_passes_target_through(page): + action = ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK) + assert action.args["url"].target is ft.UrlTarget.BLANK + # "_blank" is the reserved keyword; "blank" would be read as a window name. + assert pack(action)["args"]["url"]["target"] == "_blank" + + +def test_pick_files_targets_the_users_file_picker(page): + picker = ft.FilePicker() + action = ft.PickFiles(picker, allow_multiple=True) + assert action.service_id == picker._i + assert action.method == "pick_files" + assert action.args["allow_multiple"] is True + + +def test_action_is_carried_by_every_control_that_declares_one(page): + for control in [ + ft.Button("x"), + ft.IconButton(icon=ft.Icons.ADD), + ft.Container(), + ft.ListTile(), + ft.FloatingActionButton(icon=ft.Icons.ADD), + ft.OutlinedButton("x"), + ft.TextButton("x"), + ft.CupertinoButton(content="x"), + ft.CupertinoListTile(title=ft.Text("x")), + ft.TextSpan("x"), + ]: + assert isinstance(control, ft.ActionControl) + control.action = ft.CopyToClipboard("hello") + assert pack(control)["action"]["method"] == "set" + + +def test_action_is_omitted_when_unset(page): + assert "action" not in pack(ft.Button("x")) From 2124e7750b6a8d66f48c595a35ce9a2c0402dcf1 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 29 Aug 2026 12:30:55 -0700 Subject: [PATCH 09/10] Add explicit SELF target to open_url_action example Makes the 'Open in this tab' button's intent explicit by passing `target=ft.UrlTarget.SELF` to `ft.OpenUrl`. --- .../examples/services/url_launcher/open_url_action/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/examples/services/url_launcher/open_url_action/main.py b/sdk/python/examples/services/url_launcher/open_url_action/main.py index ea23eea535..3d31ec47d3 100644 --- a/sdk/python/examples/services/url_launcher/open_url_action/main.py +++ b/sdk/python/examples/services/url_launcher/open_url_action/main.py @@ -13,7 +13,7 @@ def main(page: ft.Page): ft.Text("Both buttons open the same page:"), ft.Button( "Open in this tab", - action=ft.OpenUrl("https://flet.dev"), + action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.SELF), ), ft.Button( "Open in a new tab", From 11a9f16747464e1513c94f33de7bcfebecb70f84 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 2 Sep 2026 13:43:34 -0700 Subject: [PATCH 10/10] refactor: move each client action into its service's module Review feedback: group the action classes with the services they belong to rather than in one shared module. PickFiles -> file_picker.py, CopyToClipboard -> clipboard.py, OpenUrl -> url_launcher.py, ShareText -> share.py. ClientAction stays as the base. This inverts the import direction and removes the workarounds the shared module needed: four deferred imports inside __post_init__ that existed only to dodge a cycle, a TYPE_CHECKING block for FilePicker, and - the worst of them - ShareText reaching across a module boundary for share.py's private _share_args, which is now a local call. _shared_service/_action_field lose their underscores since they are now imported by the service modules; their docstrings say they are internal but shared. PickFiles needs neither, as it targets the user's own FilePicker. Also matches the existing convention: file_picker.py already holds FilePickerFile, FilePickerFileType, FilePickerResultEvent and the upload types. Public API is unchanged - ft.PickFiles and friends resolve exactly as before; only the lazy-import map's module paths move. --- sdk/python/packages/flet/src/flet/__init__.py | 21 +- .../flet/src/flet/controls/client_action.py | 235 ++---------------- .../src/flet/controls/services/clipboard.py | 31 ++- .../src/flet/controls/services/file_picker.py | 89 +++++++ .../flet/src/flet/controls/services/share.py | 47 ++++ .../flet/controls/services/url_launcher.py | 42 +++- 6 files changed, 230 insertions(+), 235 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index f382de9d72..34e1c8c5f7 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -115,13 +115,7 @@ ShapeBorder, StadiumBorder, ) - from flet.controls.client_action import ( - ClientAction, - CopyToClipboard, - OpenUrl, - PickFiles, - ShareText, - ) + from flet.controls.client_action import ClientAction from flet.controls.colors import Colors from flet.controls.context import ( Context, @@ -563,7 +557,7 @@ BatteryStateChangeEvent, ) from flet.controls.services.browser_context_menu import BrowserContextMenu - from flet.controls.services.clipboard import Clipboard + from flet.controls.services.clipboard import Clipboard, CopyToClipboard from flet.controls.services.connectivity import ( Connectivity, ConnectivityChangeEvent, @@ -576,6 +570,7 @@ FilePickerResultEvent, FilePickerUploadEvent, FilePickerUploadFile, + PickFiles, ) from flet.controls.services.gyroscope import ( Gyroscope, @@ -603,12 +598,14 @@ ShareFile, ShareResult, ShareResultStatus, + ShareText, ) from flet.controls.services.shared_preferences import SharedPreferences from flet.controls.services.storage_paths import StoragePaths from flet.controls.services.url_launcher import ( BrowserConfiguration, LaunchMode, + OpenUrl, UrlLauncher, WebViewConfiguration, ) @@ -1376,7 +1373,7 @@ "ControlEventHandler": "flet.controls.control_event", "ControlState": "flet.controls.control_state", "ControlStateValue": "flet.controls.control_state", - "CopyToClipboard": "flet.controls.client_action", + "CopyToClipboard": "flet.controls.services.clipboard", "CrossAxisAlignment": "flet.controls.types", "CupertinoActionSheet": "flet.controls.cupertino.cupertino_action_sheet", "CupertinoActionSheetAction": "flet.controls.cupertino.cupertino_action_sheet_action", # noqa: E501 @@ -1574,7 +1571,7 @@ "OffsetValue": "flet.controls.transform", "OnReorderEvent": "flet.controls.material.reorderable_list_view", "OnScrollEvent": "flet.controls.scrollable_control", - "OpenUrl": "flet.controls.client_action", + "OpenUrl": "flet.controls.services.url_launcher", "Orientation": "flet.controls.types", "OutlinedBorder": "flet.controls.buttons", "OutlinedButton": "flet.controls.material.outlined_button", @@ -1597,7 +1594,7 @@ "PaintRadialGradient": "flet.controls.painting", "PaintSweepGradient": "flet.controls.painting", "PaintingStyle": "flet.controls.painting", - "PickFiles": "flet.controls.client_action", + "PickFiles": "flet.controls.services.file_picker", "Placeholder": "flet.controls.core.placeholder", "PlatformBrightnessChangeEvent": "flet.controls.page", "PointerDeviceType": "flet.controls.types", @@ -1671,7 +1668,7 @@ "ShareFile": "flet.controls.services.share", "ShareResult": "flet.controls.services.share", "ShareResultStatus": "flet.controls.services.share", - "ShareText": "flet.controls.client_action", + "ShareText": "flet.controls.services.share", "SharedPreferences": "flet.controls.services.shared_preferences", "Shimmer": "flet.controls.core.shimmer", "ShimmerDirection": "flet.controls.core.shimmer", diff --git a/sdk/python/packages/flet/src/flet/controls/client_action.py b/sdk/python/packages/flet/src/flet/controls/client_action.py index 5a2163b536..c389ff2f32 100644 --- a/sdk/python/packages/flet/src/flet/controls/client_action.py +++ b/sdk/python/packages/flet/src/flet/controls/client_action.py @@ -1,21 +1,11 @@ from dataclasses import MISSING, dataclass, field -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union +from typing import Any, Optional, TypeVar from weakref import WeakKeyDictionary from flet.controls.context import context from flet.controls.services.service import Service -from flet.controls.types import Url, UrlTarget -if TYPE_CHECKING: - from flet.controls.services.file_picker import FilePicker, FilePickerFileType - -__all__ = [ - "ClientAction", - "CopyToClipboard", - "OpenUrl", - "PickFiles", - "ShareText", -] +__all__ = ["ClientAction"] S = TypeVar("S", bound=Service) @@ -31,10 +21,13 @@ class would put a class object on the wire. """ -def _shared_service(service_type: type[S]) -> S: +def shared_service(service_type: type[S]) -> S: """ Returns the page's single instance of `service_type`, creating it on first use. + Internal, but shared: each action lives in its own service's module and calls + this to reach a service the user never instantiated. + Client actions are attached to controls, so a page can easily hold dozens of them. Instantiating a service per action would register a service per action with the client for no benefit, since these services carry no per-action @@ -49,10 +42,13 @@ def _shared_service(service_type: type[S]) -> S: return service -def _action_field(default: Any = MISSING): +def action_field(default: Any = MISSING): """ Declares a field that configures an action but is not sent to the client. + Internal, but shared: used by the `ClientAction` subclasses, which live in + the modules of the services they drive. + Only `service_id`, `method` and `args` cross the wire; the properties users set are kept readable on the Python object without duplicating them into the payload. Omit `default` to make the field required. @@ -84,8 +80,10 @@ class ClientAction: set it on the control ahead of the click instead. Note: - Actions are not constructed directly - use one of the subclasses, such as - :class:`~flet.OpenUrl`. + Actions are not constructed directly - use one of the subclasses, each of + which lives alongside the service it drives: + :class:`~flet.OpenUrl`, :class:`~flet.CopyToClipboard`, + :class:`~flet.PickFiles` and :class:`~flet.ShareText`. """ service_id: int = field(init=False, default=0) @@ -118,208 +116,3 @@ def _bind(self, service: Service, method: str, args: dict[str, Any]) -> None: self.service_id = service._i self.method = method self.args = {k: v for k, v in args.items() if v is not None} - - -@dataclass -class OpenUrl(ClientAction): - """ - Opens a URL when the control is activated. - - Equivalent to :meth:`flet.UrlLauncher.launch_url`, but performed by the - client inside the user's gesture, so that opening a new tab is not blocked as - an unsolicited popup. - - Example: - ```python - ft.Button( - "Open flet.dev", - action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK), - ) - ``` - """ - - url: str = _action_field() - """ - The URL to open. - """ - - target: Optional[Union[UrlTarget, str]] = _action_field(None) - """ - Where to open the URL, for example :attr:`flet.UrlTarget.BLANK` for a new tab. - - Web-only; ignored on other platforms. - """ - - def __post_init__(self) -> None: - from flet.controls.services.url_launcher import UrlLauncher - - self._bind( - _shared_service(UrlLauncher), - "launch_url", - {"url": Url(url=self.url, target=self.target)}, - ) - - -@dataclass -class CopyToClipboard(ClientAction): - """ - Copies text to the clipboard when the control is activated. - - Equivalent to :meth:`flet.Clipboard.set`, but performed by the client inside - the user's gesture, which is the only time Safari permits a page to write to - the clipboard. - - Example: - ```python - ft.Button("Copy token", action=ft.CopyToClipboard(token)) - ``` - """ - - data: str = _action_field() - """ - The text to copy. - - Must be known before the control is activated. To copy something computed at - click time, assign it to this action ahead of the click. - """ - - def __post_init__(self) -> None: - from flet.controls.services.clipboard import Clipboard - - self._bind(_shared_service(Clipboard), "set", {"data": self.data}) - - -@dataclass -class PickFiles(ClientAction): - """ - Opens a file picker dialog when the control is activated. - - Equivalent to :meth:`flet.FilePicker.pick_files`, but performed by the - client inside the user's gesture, which is the only time a browser opens a - file picker. This is what makes file picking work in a web app on iOS. - - Unlike `pick_files()`, the selection is not returned to the caller - it - arrives at :attr:`flet.FilePicker.on_result`. The files stay associated with - `file_picker`, so they can be passed straight to - :meth:`flet.FilePicker.upload`. - - Example: - ```python - picker = ft.FilePicker(on_result=handle_result) - page.services.append(picker) - - ft.Button("Upload", action=ft.PickFiles(picker, allow_multiple=True)) - ``` - """ - - file_picker: "FilePicker" = _action_field() - """ - The :class:`~flet.FilePicker` that opens the dialog and reports the result - through its :attr:`~flet.FilePicker.on_result` event. - """ - - dialog_title: Optional[str] = _action_field(None) - """ - The title of the dialog window. - """ - - initial_directory: Optional[str] = _action_field(None) - """ - The initial directory where the dialog should open. - """ - - file_type: Optional["FilePickerFileType"] = _action_field(None) - """ - The file types allowed to be selected. - """ - - allowed_extensions: Optional[list[str]] = _action_field(None) - """ - The allowed file extensions. Has effect only if :attr:`file_type` is - :attr:`flet.FilePickerFileType.CUSTOM`. - """ - - allow_multiple: bool = _action_field(False) - """ - Allow the selection of multiple files at once. - """ - - with_data: bool = _action_field(False) - """ - Read selected file contents into :attr:`flet.FilePickerFile.bytes`. - """ - - compression_quality: int = _action_field(0) - """ - Image compression quality from `0` to `100`. `0` disables compression. - """ - - cancel_upload_on_window_blur: bool = _action_field(True) - """ - Web-only. Whether to treat browser window blur as a cancelled selection. - """ - - def __post_init__(self) -> None: - from flet.controls.services.file_picker import FilePickerFileType - - self._bind( - self.file_picker, - "pick_files", - { - "dialog_title": self.dialog_title, - "initial_directory": self.initial_directory, - "file_type": self.file_type or FilePickerFileType.ANY, - "allowed_extensions": self.allowed_extensions, - "allow_multiple": self.allow_multiple, - "with_data": self.with_data, - "compression_quality": self.compression_quality, - "cancel_upload_on_window_blur": self.cancel_upload_on_window_blur, - }, - ) - - -@dataclass -class ShareText(ClientAction): - """ - Opens the platform share sheet with text when the control is activated. - - Equivalent to :meth:`flet.Share.share_text`, but performed by the client - inside the user's gesture, which is the only time a browser permits - `navigator.share`. - - The share result is not reported back - use - :meth:`flet.Share.share_text` if you need it, keeping in mind that it does - not work on the web on iOS. - - Example: - ```python - ft.Button( - "Share", - action=ft.ShareText("Check out Flet", subject="Flet"), - ) - ``` - """ - - text: str = _action_field() - """ - The text to share. - """ - - title: Optional[str] = _action_field(None) - """ - Title shown in the share sheet. - """ - - subject: Optional[str] = _action_field(None) - """ - Subject used by targets that support one, such as email. - """ - - def __post_init__(self) -> None: - from flet.controls.services.share import Share, _share_args - - self._bind( - _shared_service(Share), - "share_text", - _share_args(text=self.text, title=self.title, subject=self.subject), - ) diff --git a/sdk/python/packages/flet/src/flet/controls/services/clipboard.py b/sdk/python/packages/flet/src/flet/controls/services/clipboard.py index 0a235e6097..15ea45975f 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/clipboard.py +++ b/sdk/python/packages/flet/src/flet/controls/services/clipboard.py @@ -1,11 +1,13 @@ +from dataclasses import dataclass from typing import Optional from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field, shared_service from flet.controls.exceptions import FletUnsupportedPlatformException from flet.controls.services.service import Service from flet.controls.types import PagePlatform -__all__ = ["Clipboard"] +__all__ = ["Clipboard", "CopyToClipboard"] @control("Clipboard") @@ -100,3 +102,30 @@ async def get_files(self) -> list[str]: "get_files is supported on desktop and Android platforms only" ) return await self._invoke_method("get_files") + + +@dataclass +class CopyToClipboard(ClientAction): + """ + Copies text to the clipboard when the control is activated. + + Equivalent to :meth:`flet.Clipboard.set`, but performed by the client inside + the user's gesture, which is the only time Safari permits a page to write to + the clipboard. + + Example: + ```python + ft.Button("Copy token", action=ft.CopyToClipboard(token)) + ``` + """ + + data: str = action_field() + """ + The text to copy. + + Must be known before the control is activated. To copy something computed at + click time, assign it to this action ahead of the click. + """ + + def __post_init__(self) -> None: + self._bind(shared_service(Clipboard), "set", {"data": self.data}) diff --git a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py index f543ec6c0c..a3b695e289 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py +++ b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py @@ -3,6 +3,7 @@ from typing import Any, Optional from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field from flet.controls.control_event import Event, EventHandler from flet.controls.exceptions import FletUnsupportedPlatformException from flet.controls.services.service import Service @@ -14,6 +15,7 @@ "FilePickerResultEvent", "FilePickerUploadEvent", "FilePickerUploadFile", + "PickFiles", ] @@ -394,3 +396,90 @@ def _normalize_file(self, file: dict[str, Any]) -> dict[str, Any]: if isinstance(value, list): file["bytes"] = bytes(value) return file + + +@dataclass +class PickFiles(ClientAction): + """ + Opens a file picker dialog when the control is activated. + + Equivalent to :meth:`flet.FilePicker.pick_files`, but performed by the + client inside the user's gesture, which is the only time a browser opens a + file picker. This is what makes file picking work in a web app on iOS. + + Unlike `pick_files()`, the selection is not returned to the caller - it + arrives at :attr:`flet.FilePicker.on_result`. The files stay associated with + `file_picker`, so they can be passed straight to + :meth:`flet.FilePicker.upload`. + + Example: + ```python + picker = ft.FilePicker(on_result=handle_result) + page.services.append(picker) + + ft.Button("Upload", action=ft.PickFiles(picker, allow_multiple=True)) + ``` + """ + + file_picker: FilePicker = action_field() + """ + The :class:`~flet.FilePicker` that opens the dialog and reports the result + through its :attr:`~flet.FilePicker.on_result` event. + """ + + dialog_title: Optional[str] = action_field(None) + """ + The title of the dialog window. + """ + + initial_directory: Optional[str] = action_field(None) + """ + The initial directory where the dialog should open. + """ + + file_type: Optional[FilePickerFileType] = action_field(None) + """ + The file types allowed to be selected. + """ + + allowed_extensions: Optional[list[str]] = action_field(None) + """ + The allowed file extensions. Has effect only if :attr:`file_type` is + :attr:`flet.FilePickerFileType.CUSTOM`. + """ + + allow_multiple: bool = action_field(False) + """ + Allow the selection of multiple files at once. + """ + + with_data: bool = action_field(False) + """ + Read selected file contents into :attr:`flet.FilePickerFile.bytes`. + """ + + compression_quality: int = action_field(0) + """ + Image compression quality from `0` to `100`. `0` disables compression. + """ + + cancel_upload_on_window_blur: bool = action_field(True) + """ + Web-only. Whether to treat browser window blur as a cancelled selection. + """ + + def __post_init__(self) -> None: + self._bind( + self.file_picker, + "pick_files", + { + "dialog_title": self.dialog_title, + "initial_directory": self.initial_directory, + "file_type": self.file_type or FilePickerFileType.ANY, + "allowed_extensions": self.allowed_extensions, + "allow_multiple": self.allow_multiple, + "with_data": self.with_data, + "compression_quality": self.compression_quality, + "cancel_upload_on_window_blur": self.cancel_upload_on_window_blur, + }, + ) diff --git a/sdk/python/packages/flet/src/flet/controls/services/share.py b/sdk/python/packages/flet/src/flet/controls/services/share.py index 60c39ba7d0..0197635a97 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/share.py +++ b/sdk/python/packages/flet/src/flet/controls/services/share.py @@ -4,6 +4,7 @@ from typing import Optional from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field, shared_service from flet.controls.services.service import Service from flet.controls.transform import Offset from flet.utils.from_dict import from_dict @@ -14,6 +15,7 @@ "ShareFile", "ShareResult", "ShareResultStatus", + "ShareText", ] @@ -323,3 +325,48 @@ def _share_args( ] return args + + +@dataclass +class ShareText(ClientAction): + """ + Opens the platform share sheet with text when the control is activated. + + Equivalent to :meth:`flet.Share.share_text`, but performed by the client + inside the user's gesture, which is the only time a browser permits + `navigator.share`. + + The share result is not reported back - use + :meth:`flet.Share.share_text` if you need it, keeping in mind that it does + not work on the web on iOS. + + Example: + ```python + ft.Button( + "Share", + action=ft.ShareText("Check out Flet", subject="Flet"), + ) + ``` + """ + + text: str = action_field() + """ + The text to share. + """ + + title: Optional[str] = action_field(None) + """ + Title shown in the share sheet. + """ + + subject: Optional[str] = action_field(None) + """ + Subject used by targets that support one, such as email. + """ + + def __post_init__(self) -> None: + self._bind( + shared_service(Share), + "share_text", + _share_args(text=self.text, title=self.title, subject=self.subject), + ) diff --git a/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py b/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py index 59bce71e07..b01a1c2bee 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py +++ b/sdk/python/packages/flet/src/flet/controls/services/url_launcher.py @@ -3,12 +3,14 @@ from typing import Optional, Union from flet.controls.base_control import control +from flet.controls.client_action import ClientAction, action_field, shared_service from flet.controls.services.service import Service -from flet.controls.types import Url +from flet.controls.types import Url, UrlTarget __all__ = [ "BrowserConfiguration", "LaunchMode", + "OpenUrl", "UrlLauncher", "WebViewConfiguration", ] @@ -180,3 +182,41 @@ async def supports_close_for_launch_mode(self, mode: LaunchMode) -> bool: return await self._invoke_method( "supports_close_for_launch_mode", {"mode": mode} ) + + +@dataclass +class OpenUrl(ClientAction): + """ + Opens a URL when the control is activated. + + Equivalent to :meth:`flet.UrlLauncher.launch_url`, but performed by the + client inside the user's gesture, so that opening a new tab is not blocked as + an unsolicited popup. + + Example: + ```python + ft.Button( + "Open flet.dev", + action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK), + ) + ``` + """ + + url: str = action_field() + """ + The URL to open. + """ + + target: Optional[Union[UrlTarget, str]] = action_field(None) + """ + Where to open the URL, for example :attr:`flet.UrlTarget.BLANK` for a new tab. + + Web-only; ignored on other platforms. + """ + + def __post_init__(self) -> None: + self._bind( + shared_service(UrlLauncher), + "launch_url", + {"url": Url(url=self.url, target=self.target)}, + )