From 9d0cd4766d134f6f1c37f11f4812cace50235f58 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 18:38:33 +0200 Subject: [PATCH 1/4] docs --- website/docs/cookbook/multiprocessing.md | 13 +- website/docs/cookbook/subinterpreters.md | 158 +++++++++++++++++++++++ website/sidebars.yml | 1 + 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 website/docs/cookbook/subinterpreters.md diff --git a/website/docs/cookbook/multiprocessing.md b/website/docs/cookbook/multiprocessing.md index d2963ef488..98a54312ee 100644 --- a/website/docs/cookbook/multiprocessing.md +++ b/website/docs/cookbook/multiprocessing.md @@ -14,6 +14,9 @@ For I/O-bound work, or work that just needs to stay off the UI thread, prefer Reach for `multiprocessing` when you need multiple CPU cores doing Python work at the same time (number crunching, batch processing, ML inference, etc.), or when you need process isolation for work that may fail or need to be stopped. +For multi-core Python **in a single process** — including on mobile, where +`multiprocessing` cannot run — see [subinterpreters](subinterpreters.md) +(Python 3.14+). :::important[Platform and Flet version support] `multiprocessing` works in Flet desktop apps during development ([`flet run`](../cli/flet-run.md)) and @@ -22,7 +25,10 @@ or [`flet debug {macos,windows,linux}`](../cli/flet-debug.md) when using [Flet v It is **not supported on iOS and Android** (mobile operating systems don't allow apps to spawn arbitrary child processes) or **in the browser**. On those -platforms, prefer threads or `asyncio` instead. +platforms, use threads or [`asyncio`](https://docs.python.org/3/library/asyncio.html) +for I/O-bound work, and [subinterpreters](subinterpreters.md) (Python 3.14+) for +CPU-bound work — they run in-process, so they parallelize Python across cores on +mobile, where `multiprocessing` cannot. ::: ## How does it work? @@ -53,8 +59,9 @@ if __name__ == "__main__": ft.run(main) ``` -With the `spawn` and `forkserver` start methods, worker/helper processes need -to safely import your main module. `spawn` is the default on macOS and Windows; +With the `spawn` and `forkserver` +[start methods](https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods), +worker/helper processes need to safely import your main module. `spawn` is the default on macOS and Windows; `forkserver` is the default on Linux starting with Python 3.14. Without the guard, a child process can try to start your whole app again. diff --git a/website/docs/cookbook/subinterpreters.md b/website/docs/cookbook/subinterpreters.md new file mode 100644 index 0000000000..50758aa5e3 --- /dev/null +++ b/website/docs/cookbook/subinterpreters.md @@ -0,0 +1,158 @@ +--- +title: "Subinterpreters" +--- + +import {CodeExample} from '@site/src/components/crocodocs'; + +In this cookbook recipe, you'll learn how to use Python 3.14's +[subinterpreters](https://peps.python.org/pep-0734/) +([`concurrent.interpreters`](https://docs.python.org/3/library/concurrent.interpreters.html) +and [`concurrent.futures.InterpreterPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#interpreterpoolexecutor)) +for true multi-core CPU parallelism in a Flet app. + +A subinterpreter is a separate Python interpreter running **inside the same +process**. Since Python 3.12 each one has its +[own GIL](https://peps.python.org/pep-0684/), so several subinterpreters can run +pure-Python code on several CPU cores at once — without starting separate +processes. + +## When to use which + +| | runs on | true CPU parallelism | notes | +|---|---|---|---| +| [threads](async-apps.md#threading) | one interpreter, one GIL | ❌ (pure Python) | best for I/O, or C libraries that release the GIL | +| **subinterpreters** | one process, N interpreters | ✅ | in-process, works on mobile; restricted data sharing; can't force-cancel | +| [multiprocessing](multiprocessing.md) | N processes | ✅ | full isolation, can hard-cancel a worker; heavier, desktop-only in Flet | + +Reach for subinterpreters when you need multiple cores for Python work and want +to stay in one process — especially on **mobile**, where `multiprocessing` +cannot spawn child processes at all. + +:::important[Platform and version support] +Subinterpreters require **Python >=3.14** — select it with `requires-python = +">=3.14"` in your `pyproject.toml` (or `flet build --python-version 3.14`). + +They work in Flet apps on **macOS, Windows, Linux, iOS, and Android**. + +On the **web** it depends on where your Python actually runs: + +- [Dynamic websites](../publish/web/dynamic-website/index.md) run your app + server-side as an ordinary CPython process (FastAPI/Uvicorn), so + subinterpreters work just like on desktop — as long as the **server** runs + Python 3.14. +- [Static websites](../publish/web/static-website/index.md) run entirely in the + browser on [Pyodide](https://pyodide.org/en/stable/index.html), a + [single-threaded WebAssembly runtime](https://pyodide.org/en/stable/usage/wasm-constraints.html) + with no per-interpreter GIL — so subinterpreters are **not** available there. +::: + +## Rules + +### Define workers at module top level + +To run a worker in another interpreter, CPython **copies it there** — its code +plus the module-level functions and constants it references. Define workers at +the **top level** of a module (your `main.py`, as the examples below do, or a +separate file); both behave identically on macOS, Windows, Linux, iOS, and +Android. + +A worker function **nested** inside `main()` or a button handler only works if it is +*stateless* — no captured variables and no module globals — so the moment it +references a helper or a constant it fails with `NotShareableError: only +stateless functions are shareable`. A top-level function has no such limit: it +can freely call other module-level helpers. (Also don't call a helper from inside +a generator expression — see [Caveats](#caveats).) + +### Pass only picklable / shareable data + +Arguments and return values are +[pickled](https://docs.python.org/3/library/pickle.html) to cross the interpreter +boundary, so they must be picklable. The low-level +[`Queue`](https://docs.python.org/3/library/concurrent.interpreters.html#concurrent.interpreters.Queue) +additionally accepts *shareable* objects directly — numbers, `str`, `bytes`, +`None`, tuples of those, and the queue itself. Don't pass Flet controls, `page`, +open files, or database connections. + +### Don't touch the GUI from a subinterpreter + +Workers run in an isolated interpreter with no access to your page. Return data +(or stream it through a `Queue`) and update the UI from the main interpreter. + +## Examples + +### Parallel map across cores + +[`InterpreterPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#interpreterpoolexecutor) +is a drop-in alternative to +[`ProcessPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#processpoolexecutor): +it runs each task in a subinterpreter and, because each has its own GIL, uses +several cores at once — all in one process. + + + +The example times the same work run sequentially and then across the pool, and +reports the speedup. Orchestration runs off the UI thread with +[`page.run_thread`](async-apps.md#threading), and parallel results are collected +as each task lands via +[`as_completed`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.as_completed). +The pool only wins when each task does enough work to outweigh the cost of +starting a subinterpreter — the speedup is largest on a multi-core desktop, and +smaller on mobile, where startup costs more and there are fewer cores. + +### Stream progress from a subinterpreter + +To show fine-grained progress from a single long job, share a +[`Queue`](https://docs.python.org/3/library/concurrent.interpreters.html#concurrent.interpreters.Queue) +with the subinterpreter. The worker `put`s progress values; a background thread +drains them into the UI: + + + +Two details worth noting: the worker runs on its own thread because +[`interp.call()`](https://docs.python.org/3/library/concurrent.interpreters.html#concurrent.interpreters.Interpreter.call) +blocks until the job finishes, and the interpreter is closed +only **after** the UI has drained every item — a subinterpreter `Queue`'s +pending items become invalid the moment its interpreter closes. + +### Keep a persistent, stateful interpreter + +Creating an interpreter with +[`interpreters.create()`](https://docs.python.org/3/library/concurrent.interpreters.html#concurrent.interpreters.create) +isn't free, so don't create one per task. Create it **once** and reuse it: the state a worker builds (here, the prime table cached in +a module global) persists in that interpreter between calls, so expensive setup +happens only on the first call. + + + +Click twice: the first query builds the prime table (slow), the second reuses it +from the live interpreter (instant). In a real app that table stands in for a +loaded model, an opened dataset, or a warmed cache. + +Because this interpreter lives for the whole session, the example registers +[`atexit`](https://docs.python.org/3/library/atexit.html) to +[`close()`](https://docs.python.org/3/library/concurrent.interpreters.html#concurrent.interpreters.Interpreter.close) +it at shutdown — otherwise Python warns that a subinterpreter was left open. + +## Caveats + +- **You can't force-cancel a subinterpreter:** It runs on a thread, so — unlike + a [`multiprocessing.Process`](https://docs.python.org/3/library/multiprocessing.html#multiprocessing.Process) + — there's no + [`terminate()`](https://docs.python.org/3/library/multiprocessing.html#multiprocessing.Process.terminate). + If you need to abort a runaway task, use [multiprocessing](multiprocessing.md) + instead. +- **Not every C extension supports subinterpreters:** An extension must opt in + (multi-phase initialization with per-interpreter GIL support); some + third-party native libraries don't yet and raise + [`ImportError`](https://docs.python.org/3/library/exceptions.html#ImportError) + when imported in a subinterpreter. Pure-Python code and the standard library work. +- **Reuse interpreters and pools:** Interpreter startup isn't free (each + re-imports its modules); create a pool or a persistent interpreter once rather + than per task. +- **Call module-level helpers from a loop or list comprehension, not a generator + expression:** The names a generator expression looks up aren't carried into the + subinterpreter, so `sum(f(x) for x in ...)` referencing a module-level `f` + raises [`NameError`](https://docs.python.org/3/library/exceptions.html#NameError) + — in a module just as much as in `main.py`. A plain `for` + loop or a list comprehension (which Python inlines) works instead, as these + examples do. diff --git a/website/sidebars.yml b/website/sidebars.yml index 90c24e9b9f..3ad9c0a9d5 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -38,6 +38,7 @@ docs: PubSub: cookbook/pub-sub.md Subprocess: cookbook/subprocess.md Multiprocessing: cookbook/multiprocessing.md + Subinterpreters: cookbook/subinterpreters.md Logging: cookbook/logging.md Authentication: cookbook/authentication.md Encrypting sensitive data: cookbook/encrypting-sensitive-data.md From 087df97d110800eebbd22fd496c3d84c2e18dc40 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 18:38:52 +0200 Subject: [PATCH 2/4] examples --- .../cookbook/subinterpreters/parallel_map.py | 81 ++++++++++++++ .../subinterpreters/persistent_interpreter.py | 82 ++++++++++++++ .../subinterpreters/streaming_queue.py | 100 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 sdk/python/examples/cookbook/subinterpreters/parallel_map.py create mode 100644 sdk/python/examples/cookbook/subinterpreters/persistent_interpreter.py create mode 100644 sdk/python/examples/cookbook/subinterpreters/streaming_queue.py diff --git a/sdk/python/examples/cookbook/subinterpreters/parallel_map.py b/sdk/python/examples/cookbook/subinterpreters/parallel_map.py new file mode 100644 index 0000000000..2f77deee61 --- /dev/null +++ b/sdk/python/examples/cookbook/subinterpreters/parallel_map.py @@ -0,0 +1,81 @@ +import time +from concurrent.futures import InterpreterPoolExecutor, as_completed + +import flet as ft + + +def _is_prime(n: int) -> bool: + """Returns True if `n` is prime.""" + if n < 2: + return False + for d in range(2, int(n**0.5) + 1): + if n % d == 0: + return False + return True + + +def count_primes(limit: int) -> int: + """Count the primes below `limit` (CPU-bound, pure Python).""" + count = 0 + for n in range(2, limit): + if _is_prime(n): + count += 1 + return count + + +def main(page: ft.Page): + def run(): + """Time the same work sequentially and across a pool, then report the + speedup. Runs on a background thread so the UI stays responsive.""" + limits = [200_000 + i * 30_000 for i in range(8)] + + # Baseline: run every chunk in this one interpreter (one core). + status.value = "Sequential…" + progress.value = 0 + page.update() + started = time.perf_counter() + for done, limit in enumerate(limits, 1): + count_primes(limit) + progress.value = done / len(limits) + page.update() + seq_time = time.perf_counter() - started + + # Parallel: one subinterpreter per chunk, each with its own GIL. + status.value = "Parallel…" + progress.value = 0 + page.update() + primes = completed = 0 + started = time.perf_counter() + with InterpreterPoolExecutor() as pool: # sizes itself to the CPU count + futures = [pool.submit(count_primes, n) for n in limits] + for future in as_completed(futures): + primes += future.result() + completed += 1 + progress.value = completed / len(futures) + page.update() + par_time = time.perf_counter() - started + + status.value = ( + f"{primes} primes · sequential {seq_time:.1f}s · " + f"parallel {par_time:.1f}s · {seq_time / par_time:.1f}× faster" + ) + page.update() + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Button( + "Count primes: sequential vs parallel", + on_click=lambda: page.run_thread(run), + ), + progress := ft.ProgressBar(value=0, width=300), + status := ft.Text(), + ] + ) + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/cookbook/subinterpreters/persistent_interpreter.py b/sdk/python/examples/cookbook/subinterpreters/persistent_interpreter.py new file mode 100644 index 0000000000..a38d24d2f4 --- /dev/null +++ b/sdk/python/examples/cookbook/subinterpreters/persistent_interpreter.py @@ -0,0 +1,82 @@ +import atexit +import time +from concurrent import interpreters + +import flet as ft + +_UPPER = 2_000_000 +_primes: list[int] | None = None + + +def _is_prime(n: int) -> bool: + """Returns True if `n` is prime.""" + if n < 2: + return False + for d in range(2, int(n**0.5) + 1): + if n % d == 0: + return False + return True + + +def nth_prime(n: int) -> dict: + """Returns the n-th prime (1-indexed), building a prime table on first call. + + Runs in a long-lived subinterpreter that keeps its state across calls, so + the table is built once (cached in a module global) and reused after that. + The build stands in for genuinely expensive setup — loading a model, opening + a dataset, warming a cache. + """ + global _primes + built = _primes is None + if built: + table = [] + for x in range(2, _UPPER): + if _is_prime(x): + table.append(x) + _primes = table + return { + "prime": _primes[n - 1], + "built_this_call": built, + "table_size": len(_primes), + } + + +def main(page: ft.Page): + # One long-lived subinterpreter, created once and reused for every query, + # so its cached state survives. + interp = interpreters.create() + atexit.register(interp.close) # close it at exit + + def query(): + button.disabled = True + page.update() + page.run_thread(run) + + def run(): + """Call the interpreter on a background thread and show the result.""" + started = time.perf_counter() + result = interp.call(nth_prime, 100_000) + elapsed = time.perf_counter() - started + how = "built the table" if result["built_this_call"] else "reused cache" + status.value = f"100,000th prime = {result['prime']}\n{how} in {elapsed:.2f}s" + button.disabled = False + page.update() + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + ft.Text( + "Click twice: the first query builds a prime table, the " + "second reuses it from the live interpreter." + ), + button := ft.Button("Find the 100,000th prime", on_click=query), + status := ft.Text(), + ] + ) + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/cookbook/subinterpreters/streaming_queue.py b/sdk/python/examples/cookbook/subinterpreters/streaming_queue.py new file mode 100644 index 0000000000..04d733245f --- /dev/null +++ b/sdk/python/examples/cookbook/subinterpreters/streaming_queue.py @@ -0,0 +1,100 @@ +import threading +from concurrent import interpreters + +import flet as ft + + +def _is_prime(n: int) -> bool: + """Returns True if `n` is prime.""" + if n < 2: + return False + for d in range(2, int(n**0.5) + 1): + if n % d == 0: + return False + return True + + +def _count_in_range(lo: int, hi: int) -> int: + """Returns the number of primes in the half-open range [lo, hi).""" + count = 0 + for n in range(lo, hi): + if _is_prime(n): + count += 1 + return count + + +def stream_primes(progress_queue, chunks: int, per_chunk: int) -> None: + """Count primes in `chunks` slices, reporting progress after each one. + + Runs in a subinterpreter, which has no access to the page — the queue is + the only channel back to the UI. Values are fractions 0..1; a final `None` + tells the consumer there is nothing more to read. + """ + for i in range(chunks): + lo = i * per_chunk + 2 + _count_in_range(lo, lo + per_chunk) + progress_queue.put((i + 1) / chunks) + progress_queue.put(None) # sentinel: no more updates + + +def main(page: ft.Page): + def start(): + button.disabled = True + status.value = "Working…" + page.update() + + # A queue shared between this interpreter and the subinterpreter. Only + # "shareable" objects cross it (numbers, str, bytes, None, tuples of + # those, and the queue itself). + queue = interpreters.create_queue() + + interp = interpreters.create() + drained = threading.Event() + + # The worker runs the job in the subinterpreter and blocks that thread, + # so it goes on its own background thread… + page.run_thread(work, interp, queue, drained) + # …while a second thread drains progress and drives the UI. + page.run_thread(drain, queue, drained) + + def work(interp, queue, drained): + """Run the job in the subinterpreter, then close it once the UI is done. + + On its own thread because interp.call() blocks until the job finishes. + The interpreter is closed only after `drained` is set — a subinterpreter + Queue's pending items go invalid the moment its interpreter closes. + """ + interp.call(stream_primes, queue, 20, 100_000) + drained.wait() + interp.close() + + def drain(queue, drained): + """Forward the worker's progress reports to the UI. + + Runs on a background thread: queue.get() blocks until the worker + reports again, so it must stay off the UI event loop. + """ + while (value := queue.get()) is not None: + progress.value = value + status.value = f"Counting… {value:.0%}" + page.update() + drained.set() + status.value = "Done!" + button.disabled = False + page.update() + + page.add( + ft.SafeArea( + content=ft.Column( + controls=[ + button := ft.Button("Start", on_click=start), + progress := ft.ProgressBar(value=0, width=300), + status := ft.Text(), + ] + ) + ) + ) + + +if __name__ == "__main__": + ft.run(main) From 464f52bc6075205090819cc5d29f3535ea0b72d5 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 18:39:03 +0200 Subject: [PATCH 3/4] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddebec747d..155a9b6830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.0.0 + +### Documentation + +* Add a [**Subinterpreters** cookbook page](https://docs.flet.dev/cookbook/subinterpreters) on using Python 3.14's [`concurrent.interpreters`](https://docs.python.org/3/library/concurrent.interpreters.html) and [`InterpreterPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#interpreterpoolexecutor) for true multi-core CPU parallelism inside a single Flet process — the in-process, mobile-capable counterpart to [Multiprocessing](https://docs.flet.dev/cookbook/multiprocessing), which can't spawn child processes on iOS/Android. Walks through three runnable examples — a parallel pool map, streaming progress over a shared cross-interpreter `Queue`, and a reused long-lived interpreter — with the rules and gotchas for each. Works on desktop and mobile with the bundled Python 3.14; not in static (Pyodide) web builds by @ndonkoHenri. + ## 0.86.7 ### Bug fixes From 99b59c227c8a0dfb88d883b983f79b23917ebe13 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 19 Aug 2026 19:05:52 +0200 Subject: [PATCH 4/4] updates --- CHANGELOG.md | 2 +- .../examples/cookbook/subinterpreters/parallel_map.py | 10 ++++++++-- website/docs/cookbook/subinterpreters.md | 5 +++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 155a9b6830..c899f8a34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Documentation -* Add a [**Subinterpreters** cookbook page](https://docs.flet.dev/cookbook/subinterpreters) on using Python 3.14's [`concurrent.interpreters`](https://docs.python.org/3/library/concurrent.interpreters.html) and [`InterpreterPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#interpreterpoolexecutor) for true multi-core CPU parallelism inside a single Flet process — the in-process, mobile-capable counterpart to [Multiprocessing](https://docs.flet.dev/cookbook/multiprocessing), which can't spawn child processes on iOS/Android. Walks through three runnable examples — a parallel pool map, streaming progress over a shared cross-interpreter `Queue`, and a reused long-lived interpreter — with the rules and gotchas for each. Works on desktop and mobile with the bundled Python 3.14; not in static (Pyodide) web builds by @ndonkoHenri. +* Add a [**Subinterpreters** cookbook page](https://docs.flet.dev/cookbook/subinterpreters) on using Python 3.14's [`concurrent.interpreters`](https://docs.python.org/3/library/concurrent.interpreters.html) and [`InterpreterPoolExecutor`](https://docs.python.org/3/library/concurrent.futures.html#interpreterpoolexecutor) for true multi-core CPU parallelism inside a single Flet process — the in-process, mobile-capable counterpart to [Multiprocessing](https://docs.flet.dev/cookbook/multiprocessing), which can't spawn child processes on iOS/Android. Walks through three runnable examples — a parallel pool map, streaming progress over a shared cross-interpreter `Queue`, and a reused long-lived interpreter — with the rules and gotchas for each. Works on desktop and mobile with the bundled Python 3.14; not in static (Pyodide) web builds ([#6782](https://github.com/flet-dev/flet/pull/6782)) by @ndonkoHenri. ## 0.86.7 diff --git a/sdk/python/examples/cookbook/subinterpreters/parallel_map.py b/sdk/python/examples/cookbook/subinterpreters/parallel_map.py index 2f77deee61..18f5890124 100644 --- a/sdk/python/examples/cookbook/subinterpreters/parallel_map.py +++ b/sdk/python/examples/cookbook/subinterpreters/parallel_map.py @@ -24,6 +24,11 @@ def count_primes(limit: int) -> int: def main(page: ft.Page): + def start(): + button.disabled = True # block a second run while this one is in flight + page.update() + page.run_thread(run) + def run(): """Time the same work sequentially and across a pool, then report the speedup. Runs on a background thread so the UI stays responsive.""" @@ -59,15 +64,16 @@ def run(): f"{primes} primes · sequential {seq_time:.1f}s · " f"parallel {par_time:.1f}s · {seq_time / par_time:.1f}× faster" ) + button.disabled = False page.update() page.add( ft.SafeArea( content=ft.Column( controls=[ - ft.Button( + button := ft.Button( "Count primes: sequential vs parallel", - on_click=lambda: page.run_thread(run), + on_click=start, ), progress := ft.ProgressBar(value=0, width=300), status := ft.Text(), diff --git a/website/docs/cookbook/subinterpreters.md b/website/docs/cookbook/subinterpreters.md index 50758aa5e3..08e20253c8 100644 --- a/website/docs/cookbook/subinterpreters.md +++ b/website/docs/cookbook/subinterpreters.md @@ -29,8 +29,9 @@ to stay in one process — especially on **mobile**, where `multiprocessing` cannot spawn child processes at all. :::important[Platform and version support] -Subinterpreters require **Python >=3.14** — select it with `requires-python = -">=3.14"` in your `pyproject.toml` (or `flet build --python-version 3.14`). +Subinterpreters require **Python 3.14 or later**. When packaging your app using [`flet build`](../cli/flet-build.md), +ensure that the [bundled Python version](../publish/index.md#choosing-a-python-version) meets this requirement. +In development (e.g., when using [`flet run`](../cli/flet-run.md)), the Python interpreter in your virtual environment must also meet this requirement. They work in Flet apps on **macOS, Windows, Linux, iOS, and Android**.