Skip to content

Commit 6d477b1

Browse files
committed
Dart: native views from plain Dart programs, and a view example
- runNativeApp() in nativeapi runs an app on the platform's UI thread without Flutter: on macOS it takes over the main thread the Dart VM keeps parked, on Windows and Linux it starts a thread of its own, and it pumps the platform loop between Dart's timers. Backed by an event loop shim in cnativeapi (src/event_loop*), compiled by the build hook. nativeapi_flutter does not re-export it. - examples/dart_view_example: a workbench of native views (sign-in form, task list, layout playground, notes, generated avatar) and a second window that inspects its view tree and events. `dart run bin/main.dart`. - Bump core: content-sized containers and the fixes the example needed. - CI, melos and the docs cover examples/dart_*.
1 parent 60eb469 commit 6d477b1

35 files changed

Lines changed: 2332 additions & 8 deletions

‎.github/workflows/dart-ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66
paths:
77
- "bindings/dart/**"
88
- "core"
9+
- "examples/dart_*/**"
910
- "examples/flutter_*/**"
1011
- "pubspec.yaml"
1112
- ".github/workflows/dart-ci.yml"
@@ -14,6 +15,7 @@ on:
1415
paths:
1516
- "bindings/dart/**"
1617
- "core"
18+
- "examples/dart_*/**"
1719
- "examples/flutter_*/**"
1820
- "pubspec.yaml"
1921
- ".github/workflows/dart-ci.yml"

‎AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ bindings/
1212
├── csharp/ # the C# binding: src/, tests/, NativeAPI.slnx
1313
├── js/ # the JS/TS binding: a Node-API addon (src/) + TypeScript (lib/)
1414
└── python/ # the Python binding: ctypes package (nativeapi/) + native shim (src/)
15-
examples/ # every binding's example apps, prefixed flutter_*, rust_*, csharp_*, js_*, python_*
15+
examples/ # every binding's example apps, prefixed dart_*, flutter_*, rust_*, csharp_*, js_*, python_*
1616
pubspec.yaml # pub workspace + melos root: Dart packages and Flutter examples
1717
Cargo.toml # cargo workspace root: Rust crates and examples
1818
tools/codegen/ # in-repo Rust workspace: the code generator
@@ -95,8 +95,8 @@ scenarios for *this project's* examples live in [tools/gui/](tools/gui/README.md
9595
- `core` tracks `branch = main`. Use `make sync` to fast-forward it; `make status` to see dirty state everywhere; `make bump` to stage its pointer.
9696
- The leanflutter packages built on nativeapi (`tray_manager`, `window_manager`, `launch_at_startup`, …) live in their own repos under github.com/leanflutter and depend on the published `nativeapi`; they are not part of this repo. To try one against local changes, point a `dependency_overrides` entry in that package at `bindings/dart/nativeapi_flutter` (and `nativeapi`, `cnativeapi`) and never commit the override.
9797
- Commit workspace submodule pointer updates only when the combination is compatible (a known-good snapshot).
98-
- Examples live in `examples/<binding>_<name>_example` (`flutter_`, `rust_`, `csharp_`, `js_`, `python_`), not inside the bindings; a new Flutter or Rust example must also be listed in the root `pubspec.yaml` / `Cargo.toml`, a C# one in `bindings/csharp/NativeAPI.slnx`, a JS one in the root `package.json`. A Python example is a standalone uv project whose `pyproject.toml` points `nativeapi` at `../../bindings/python` (`uv run main.py` builds the wheel). Only the pub.dev package examples (`bindings/dart/*/example`) stay inside their package.
99-
- CI is one workflow per binding (`dart-ci.yml`, `rust-ci.yml`, `csharp-ci.yml`, `js-ci.yml`, `python-ci.yml`), each running only for changes under its `bindings/<lang>/` and its examples (`examples/flutter_*` for Dart, `examples/rust_*`, `examples/csharp_*`, `examples/js_*`, `examples/python_*`). Release tags are per binding: `v*` publishes the Dart packages (`dart-release.yml`), `rust-v*` publishes the crates (`rust-release.yml`), `python-v*` publishes the Python package to PyPI (`python-release.yml`: an sdist carrying core in `cxx_impl/`, and wheels built from it with cibuildwheel; the tag must equal `python-v` + the version in `bindings/python/pyproject.toml`); never push a bare `v*` tag for anything but the Dart packages. Nothing has been published to PyPI yet: the first release needs `nativeapi` set up on PyPI with this workflow and the `pypi` environment as a trusted publisher.
98+
- Examples live in `examples/<binding>_<name>_example` (`dart_` for plain Dart programs, `flutter_`, `rust_`, `csharp_`, `js_`, `python_`), not inside the bindings; a new Dart, Flutter or Rust example must also be listed in the root `pubspec.yaml` / `Cargo.toml`, a C# one in `bindings/csharp/NativeAPI.slnx`, a JS one in the root `package.json`. A Python example is a standalone uv project whose `pyproject.toml` points `nativeapi` at `../../bindings/python` (`uv run main.py` builds the wheel). Only the pub.dev package examples (`bindings/dart/*/example`) stay inside their package.
99+
- CI is one workflow per binding (`dart-ci.yml`, `rust-ci.yml`, `csharp-ci.yml`, `js-ci.yml`, `python-ci.yml`), each running only for changes under its `bindings/<lang>/` and its examples (`examples/dart_*` and `examples/flutter_*` for Dart, `examples/rust_*`, `examples/csharp_*`, `examples/js_*`, `examples/python_*`). Release tags are per binding: `v*` publishes the Dart packages (`dart-release.yml`), `rust-v*` publishes the crates (`rust-release.yml`), `python-v*` publishes the Python package to PyPI (`python-release.yml`: an sdist carrying core in `cxx_impl/`, and wheels built from it with cibuildwheel; the tag must equal `python-v` + the version in `bindings/python/pyproject.toml`); never push a bare `v*` tag for anything but the Dart packages. Nothing has been published to PyPI yet: the first release needs `nativeapi` set up on PyPI with this workflow and the `pypi` environment as a trusted publisher.
100100
- The Dart packages `cnativeapi`, `nativeapi` and `nativeapi_flutter` share one version: a release bumps all three pubspecs and CHANGELOGs, because pub.dev's automated publishing matches the tag `v<version>` against each package's own version. `cnativeapi` and `nativeapi` are plain Dart (no Flutter dependency; a build hook compiles core); `nativeapi_flutter` is the Flutter-facing package, holding the widgets, the `dart:ui` conversions and `windowing.dart`, and re-exporting `nativeapi` minus the names that clash with Flutter's.
101101
- Never commit in a submodule while on a detached HEAD — check out `main` first (`./codegen sync` enforces this).
102102
- Do not add Co-Authored-By trailers to commits.

‎bindings/dart/README-ZH.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,39 @@ window?.isAlwaysOnTop = true;
9292

9393
所有窗口共用一个 engine 和一个 isolate,窗口之间直接通过普通 Dart 对象通信——不需要改 runner,也不需要消息通道。Flutter 的多窗口 API 仍是实验性的、属于框架内部接口,因此这个桥接单独放在 `package:nativeapi_flutter/windowing.dart` 里。它针对 **stable** channel 编写(已在 3.47.5 上验证);stable 不提供 `flutter config --enable-windowing`,所以示例在 `main()` 里直接打开 Flutter 内部的 `isWindowingEnabled`。子窗口的完整例子见 [`floating_toolbar_example`](../../examples/flutter_floating_toolbar_example),另见 [`browser_tabs_example`](../../examples/flutter_browser_tabs_example) 和 [`detachable_window_example`](../../examples/flutter_detachable_window_example)。
9494

95+
### 在纯 Dart 程序里用窗口和原生视图
96+
97+
不依赖 Flutter 的 Dart 程序也能开窗口。把应用交给 `runNativeApp()`:它让应用跑在平台的 UI 线程上,并在 Dart 的定时器和 future 之间持续驱动平台事件循环:
98+
99+
```dart
100+
import 'package:nativeapi/nativeapi.dart';
101+
102+
void app() {
103+
final window = Window.create()!..title = 'Hello';
104+
final root = window.contentView!
105+
..layout = ViewLayout.column
106+
..padding = const EdgeInsets(top: 16, right: 16, bottom: 16, left: 16)
107+
..spacing = 8;
108+
final label = Label.create('Not clicked yet')!;
109+
var clicks = 0;
110+
final button = Button.create('Click me')!
111+
..addListener((event) {
112+
if (event is ButtonClickedEvent) label.text = 'Clicked ${++clicks}×';
113+
});
114+
root
115+
..addSubview(label)
116+
..addSubview(button);
117+
window.show();
118+
}
119+
120+
void main() => runNativeApp(app);
121+
```
122+
123+
`app` 必须是顶层或静态函数:它跑在单独的 isolate 里。在 macOS 上这个 isolate 位于进程的第一个线程——AppKit 只认这个线程,而 Dart VM 平时让它闲置。应用退出(`Application.instance.quit()`、Cmd+Q)时进程结束。用这种方式写的完整应用见 [`dart_view_example`](../../examples/dart_view_example)。
124+
95125
## 示例
96126

97-
示例是仓库 [`examples/`](../../examples) 下的 `flutter_*` 目录,每个目录是对应一个模块的 Flutter 应用,依赖通过仓库根目录的 pub workspace 解析:
127+
示例是仓库 [`examples/`](../../examples) 下的 `flutter_*` 目录,外加纯 Dart 程序 [`dart_view_example`](../../examples/dart_view_example)(`dart run bin/main.dart`)。每个 Flutter 示例是对应一个模块的应用,依赖通过仓库根目录的 pub workspace 解析:
98128

99129
```bash
100130
flutter pub get # 在仓库根目录执行

‎bindings/dart/README.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,39 @@ window?.isAlwaysOnTop = true;
9292

9393
All windows share one engine and one isolate, so they talk to each other through ordinary Dart objects — no runner changes, no message channels. Flutter's multi-window API is experimental and internal to the framework, which is why the bridge lives in its own library, `package:nativeapi_flutter/windowing.dart`. It is written against the **stable** channel (checked with 3.47.5); stable does not offer `flutter config --enable-windowing`, so the examples set Flutter's internal `isWindowingEnabled` in `main()`. See [`floating_toolbar_example`](../../examples/flutter_floating_toolbar_example) for a child window built this way, and [`browser_tabs_example`](../../examples/flutter_browser_tabs_example) and [`detachable_window_example`](../../examples/flutter_detachable_window_example).
9494

95+
### Windows and native views from plain Dart
96+
97+
A Dart program without Flutter can open windows too. Hand the app to `runNativeApp()`, which runs it on the platform's UI thread and keeps the platform loop turning between Dart's timers and futures:
98+
99+
```dart
100+
import 'package:nativeapi/nativeapi.dart';
101+
102+
void app() {
103+
final window = Window.create()!..title = 'Hello';
104+
final root = window.contentView!
105+
..layout = ViewLayout.column
106+
..padding = const EdgeInsets(top: 16, right: 16, bottom: 16, left: 16)
107+
..spacing = 8;
108+
final label = Label.create('Not clicked yet')!;
109+
var clicks = 0;
110+
final button = Button.create('Click me')!
111+
..addListener((event) {
112+
if (event is ButtonClickedEvent) label.text = 'Clicked ${++clicks}×';
113+
});
114+
root
115+
..addSubview(label)
116+
..addSubview(button);
117+
window.show();
118+
}
119+
120+
void main() => runNativeApp(app);
121+
```
122+
123+
`app` must be a top-level or static function: it runs in an isolate of its own. On macOS that isolate lives on the process's first thread, the only one AppKit accepts, which the Dart VM otherwise keeps parked. The process ends when the app quits (`Application.instance.quit()`, Cmd+Q). See [`dart_view_example`](../../examples/dart_view_example) for a larger app built this way.
124+
95125
## Examples
96126

97-
The examples are the `flutter_*` directories in the repository's [`examples/`](../../examples). Each is a Flutter app for one module; they resolve through the pub workspace at the repository root:
127+
The examples are the `flutter_*` directories in the repository's [`examples/`](../../examples), plus [`dart_view_example`](../../examples/dart_view_example), a plain Dart program (`dart run bin/main.dart`). Each Flutter one is an app for one module; they resolve through the pub workspace at the repository root:
98128

99129
```bash
100130
flutter pub get # at the repository root

‎bindings/dart/cnativeapi/CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## Unreleased
2+
3+
* Event loop shim for plain Dart programs (`src/event_loop*`, bound in
4+
`lib/src/event_loop.dart`): `cnativeapi_run_ui_thread`,
5+
`cnativeapi_start_event_loop`, `cnativeapi_pump_event_loop` and
6+
`cnativeapi_exit`, compiled into the same library as the core. They back
7+
`runNativeApp()` in `nativeapi`.
8+
19
## 0.4.0
210

311
* `native_view_backend_t` and `native_view_is_backend_supported`,

‎bindings/dart/cnativeapi/hook/build.dart‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Builds the nativeapi core, C ABI included, into the shared library that the
2-
// `@Native` functions in lib/src/bindings_generated.dart resolve against.
2+
// `@Native` functions in lib/src/bindings_generated.dart resolve against,
3+
// together with this package's own event loop shim (src/, bound in
4+
// lib/src/event_loop.dart).
35
//
46
// The source lists mirror core/src/CMakeLists.txt; keep the two in step.
57

@@ -28,6 +30,13 @@ void main(List<String> args) async {
2830
_ => throw UnsupportedError('cnativeapi does not support $targetOS'),
2931
};
3032
final apple = targetOS == OS.iOS || targetOS == OS.macOS;
33+
final shim = input.packageRoot.resolve('src/');
34+
final shimSource = switch (targetOS) {
35+
OS.macOS => 'event_loop_macos.mm',
36+
OS.windows => 'event_loop_windows.cpp',
37+
OS.linux => 'event_loop_linux.cpp',
38+
_ => 'event_loop_other.cpp',
39+
};
3140

3241
final sources = [
3342
..._list(src, '', '.cpp'),
@@ -40,6 +49,7 @@ void main(List<String> args) async {
4049
r'(menu_winui3|message_dialog_winui3|window_winui3|winui3_runtime|view_winui3)_windows\.cpp$',
4150
).hasMatch(path),
4251
),
52+
shim.resolve(shimSource).toFilePath(),
4353
];
4454

4555
final flags = <String>[];
@@ -96,7 +106,7 @@ void main(List<String> args) async {
96106
name: 'cnativeapi',
97107
assetName: 'cnativeapi.dart',
98108
sources: sources,
99-
includes: [src.toFilePath()],
109+
includes: [src.toFilePath(), shim.toFilePath()],
100110
frameworks: frameworks,
101111
libraries: libraries,
102112
libraryDirectories: libraryDirectories,

‎bindings/dart/cnativeapi/lib/cnativeapi.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
library;
66

77
export 'src/bindings_generated.dart';
8+
export 'src/event_loop.dart';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Bindings for the event loop shim in src/event_loop.h, which the build hook
2+
// compiles into the same library as the core. Hand-written: ffigen only reads
3+
// the core's C API headers. The names follow the C ones, as in
4+
// bindings_generated.dart.
5+
6+
// ignore_for_file: non_constant_identifier_names
7+
8+
@ffi.DefaultAsset('package:cnativeapi/cnativeapi.dart')
9+
library;
10+
11+
import 'dart:ffi' as ffi;
12+
13+
/// Calls [entry] on the thread that becomes the UI thread, outside any
14+
/// isolate; the process ends when it returns. See src/event_loop.h.
15+
@ffi.Native<
16+
ffi.Bool Function(ffi.Pointer<ffi.NativeFunction<ffi.Void Function()>>)
17+
>()
18+
external bool cnativeapi_run_ui_thread(
19+
ffi.Pointer<ffi.NativeFunction<ffi.Void Function()>> entry,
20+
);
21+
22+
/// Prepares the platform loop. Once, on the UI thread, before the first pump.
23+
@ffi.Native<ffi.Void Function()>()
24+
external void cnativeapi_start_event_loop();
25+
26+
/// Dispatches pending platform events, waiting up to [timeout_ms] for one when
27+
/// there is none. Returns -1 to keep going, or the exit code to quit with.
28+
@ffi.Native<ffi.Int Function(ffi.Int)>()
29+
external int cnativeapi_pump_event_loop(int timeout_ms);
30+
31+
/// Flushes stdio and ends the process with [exit_code], skipping atexit
32+
/// handlers and static destructors. See src/event_loop.h.
33+
@ffi.Native<ffi.Void Function(ffi.Int)>()
34+
external void cnativeapi_exit(int exit_code);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Lets a plain Dart program (no Flutter) drive the platform event loop.
2+
//
3+
// Application::Run() blocks inside the platform loop, which would stall every
4+
// Dart timer, future and stream for the life of the app. Instead, runNativeApp()
5+
// in package:nativeapi calls cnativeapi_pump_event_loop() from a Dart timer:
6+
// each call dispatches what the platform has queued, waits a few milliseconds
7+
// for more, and returns, so the Dart event loop keeps turning in between.
8+
//
9+
// All of it runs in an isolate of its own on a thread of its own, because the
10+
// platform's UI objects belong to one thread and the Dart VM moves an isolate
11+
// between its worker threads as it pleases. cnativeapi_run_ui_thread() provides
12+
// that thread.
13+
#pragma once
14+
15+
#include <stdbool.h>
16+
#include <stdint.h>
17+
18+
#if _WIN32
19+
#define CNATIVEAPI_EXPORT __declspec(dllexport)
20+
#else
21+
#define CNATIVEAPI_EXPORT __attribute__((visibility("default")))
22+
#endif
23+
24+
#ifdef __cplusplus
25+
extern "C" {
26+
#endif
27+
28+
// Calls `entry` on the thread that becomes the UI thread, outside any isolate,
29+
// so `entry` must be a NativeCallable.isolateGroupBound. The process ends when
30+
// `entry` returns. Returns false, calling nothing, if the thread could not be
31+
// had, or on a second call.
32+
//
33+
// - macOS: AppKit only works on the process's first thread, which the
34+
// standalone Dart VM keeps parked in Dart_RunLoop() while main() runs on a
35+
// worker. That thread is interrupted with a signal and `entry` runs from the
36+
// handler, which never returns; the thread holds no lock at that point.
37+
// Refused when called on the main thread already (a Flutter app).
38+
// - Windows, Linux: a new thread, declared the main thread to the core's
39+
// dispatcher before `entry` runs. Nothing may have used nativeapi before.
40+
// - Android, iOS: always refused; the host app owns the UI thread.
41+
CNATIVEAPI_EXPORT bool cnativeapi_run_ui_thread(void (*entry)(void));
42+
43+
// Prepares the platform loop: finishes launching and activates the app on
44+
// macOS. Call once, on the main thread, before the first pump.
45+
CNATIVEAPI_EXPORT void cnativeapi_start_event_loop(void);
46+
47+
// Ends the process with `exit_code` after flushing stdio, without running
48+
// atexit handlers or static destructors: those race the Dart VM's threads,
49+
// which are still running. What the core would tear down (windows, tray icons)
50+
// goes with the process.
51+
CNATIVEAPI_EXPORT void cnativeapi_exit(int exit_code);
52+
53+
// Dispatches every pending platform event; when there was none, first waits
54+
// up to `timeout_ms` for one. Returns -1 while the loop should keep running,
55+
// or the exit code once the platform asked to quit (WM_QUIT on Windows).
56+
CNATIVEAPI_EXPORT int cnativeapi_pump_event_loop(int timeout_ms);
57+
58+
#ifdef __cplusplus
59+
}
60+
#endif
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#include "event_loop.h"
2+
3+
#include <atomic>
4+
#include <cstdio>
5+
#include <cstdlib>
6+
#include <thread>
7+
8+
#include <glib.h>
9+
10+
#include "capi/application_c.h"
11+
#include "foundation/dispatcher.h"
12+
13+
bool cnativeapi_run_ui_thread(void (*entry)(void)) {
14+
static std::atomic<bool> started{false};
15+
if (entry == nullptr || started.exchange(true)) {
16+
return false;
17+
}
18+
// GTK belongs to the thread that initialises it: any thread can be the UI
19+
// thread, as long as it stays the same one.
20+
std::thread([entry] {
21+
nativeapi::SetMainThread();
22+
entry();
23+
cnativeapi_exit(0);
24+
}).detach();
25+
return true;
26+
}
27+
28+
void cnativeapi_start_event_loop(void) {
29+
(void)native_application_is_running();
30+
}
31+
32+
namespace {
33+
gboolean WakeUp(gpointer fired) {
34+
*static_cast<bool*>(fired) = true;
35+
return G_SOURCE_REMOVE;
36+
}
37+
} // namespace
38+
39+
int cnativeapi_pump_event_loop(int timeout_ms) {
40+
// GTK dispatches everything, including RunOnMainThread() work, through the
41+
// default main context.
42+
if (!g_main_context_pending(nullptr)) {
43+
bool fired = false;
44+
guint timeout = g_timeout_add(static_cast<guint>(timeout_ms), WakeUp, &fired);
45+
g_main_context_iteration(nullptr, TRUE);
46+
if (!fired) {
47+
g_source_remove(timeout);
48+
}
49+
}
50+
while (g_main_context_iteration(nullptr, FALSE)) {
51+
}
52+
// Application::Quit() ends the process when the loop is not Run()'s own.
53+
return -1;
54+
}
55+
56+
void cnativeapi_exit(int exit_code) {
57+
fflush(nullptr);
58+
_Exit(exit_code);
59+
}

0 commit comments

Comments
 (0)