Skip to content

Commit 35eda6b

Browse files
committed
Complete macOS title-bar and application quit verification
Sync core cf2a5ce to preserve pending Flutter quit approvals. Add real application-menu and OS termination regressions, strengthen native Fill geometry checks, and document the validation harnesses. Refs libnativeapi/nativeapi-core#48 and libnativeapi/nativeapi-core#65.
1 parent a14b303 commit 35eda6b

9 files changed

Lines changed: 299 additions & 3 deletions

File tree

‎bindings/dart/cnativeapi/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## Unreleased
22

3+
- macOS: preserve a pending Flutter quit decision when AppKit acknowledges synchronous cancellation, so approving the later Dart response exits normally ([nativeapi-core#65](https://github.com/libnativeapi/nativeapi-core/issues/65)).
34
- Linux: implement tray context menu popups and dismissal on X11, including double-click triggers. Document the native Wayland requirement to use host-rendered `Clicked` or `RightClicked` menus ([nativeapi-core#88](https://github.com/libnativeapi/nativeapi-core/issues/88)).
45

56
## 0.5.10

‎bindings/dart/nativeapi/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## Unreleased
22

3+
- macOS: preserve a pending Flutter quit decision when AppKit acknowledges synchronous cancellation, so approving the later Dart response exits normally ([nativeapi-core#65](https://github.com/libnativeapi/nativeapi-core/issues/65)).
34
- Linux: implement tray context menu popups and dismissal on X11, including double-click triggers. Document the native Wayland requirement to use host-rendered `Clicked` or `RightClicked` menus ([nativeapi-core#88](https://github.com/libnativeapi/nativeapi-core/issues/88)).
45
- **Breaking (Dart):** move the experimental `runNativeApp()` helper to `package:nativeapi/standalone.dart`. Plain Dart apps using the helper must change their import; the standalone entrypoint also exports the ordinary nativeapi API. The helper still requires Dart 3.13's experimental VM APIs and is not supported on Dart 3.14.
56
- Keep the ordinary `nativeapi.dart` import independent of the experimental runner, fixing compilation on Dart 3.14 when the helper is unused ([#22](https://github.com/libnativeapi/nativeapi/issues/22)).

‎bindings/dart/nativeapi_flutter/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## Unreleased
22

3+
- macOS: preserve a pending Flutter quit decision when AppKit acknowledges synchronous cancellation, so approving the later Dart response exits normally ([nativeapi-core#65](https://github.com/libnativeapi/nativeapi-core/issues/65)).
34
- Linux: implement tray context menu popups and dismissal on X11, including double-click triggers. Document the native Wayland requirement to use host-rendered `Clicked` or `RightClicked` menus ([nativeapi-core#88](https://github.com/libnativeapi/nativeapi-core/issues/88)).
45
- Stop importing the experimental plain-Dart event-loop helper so Flutter apps compile on Dart 3.14 without the removed isolate APIs or experimental flags ([#22](https://github.com/libnativeapi/nativeapi/issues/22)).
56
- Fix iOS and Android release builds failing in gen_snapshot with `Class with illegal cid` for an event struct such as `native_window_event_t`, when an app registers listeners only on desktop platforms. Native callbacks now receive their struct payloads as `Pointer<Void>`, working around [dart-lang/sdk#50540](https://github.com/dart-lang/sdk/issues/50540) ([#26](https://github.com/libnativeapi/nativeapi/issues/26)).

‎tools/gui/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ runner) lives in [`.agents/skills/`](../../.agents/skills); read
1313
| `nsh_examples_test_macos.py` | NSH tray, shortcut and message-dialog examples | macOS | Application-scoped Accessibility actions, no mouse or keyboard events: tray hide/show across autorelease pools, native shortcut callback delivery and enable/unregister behavior, modal dialog dismissal and unsupported confirmation feedback, graceful shutdown. Run with `--build` to build first. Does not test physical hotkeys or tray-menu clicks. |
1414
| `flutter_hotkey_manager_smoke.py` | hotkey_manager submodule's own example | macOS / Windows / Linux (X11 or XWayland) | No input: real native registration and removal, asynchronous activation reflected in the Flutter counter, stale-event filtering, and reclaiming the native example registration with startup cleanup. Windows/Linux independently check OS ownership through a second client. Invokes actions through the debug VM service; activation is injected through the native manager, so physical keyboard delivery and OS key-up delivery are not covered. `verify_hotkey_manager.py` prepares local dependency overrides, analyzes/tests/builds, and imports the Linux desktop environment; `verify_hotkey_manager_windows.ps1` runs all 17 tests and the smoke check in the logged-on Windows desktop. |
1515
| `core_application_exit_flutter_macos.py` | headless Flutter exit regression | macOS | Creates no windows and sends no input. A real Dart `AppLifecycleListener` asynchronously cancels quit with and without nativeapi listeners, then approves quit. Requires Flutter, CMake and the installed debug FlutterMacOS framework; substitutes only the final process termination. |
16+
| `application_quit_gui_macos.py` | real Flutter app quit (#65), fixture `application_quit_flutter.dart` | macOS | Build `examples/flutter_window_title_bar_example` with `flutter build macos --debug --target <absolute path to tools/gui/application_quit_flutter.dart>`. Tests the app menu's Quit through Accessibility and an OS normal-termination request through `NSRunningApplication.terminate`, validating the fixture's PID and executable. Each route exercises two native async vetoes, two Flutter `AppLifecycleListener` vetoes and final approval with exactly one request per layer and a real process exit of 0 (24 checks). No mouse or keyboard input by default: policy is set through a fixture-only debug VM extension; `--pointer` uses owner-guarded clicks on its policy buttons instead. `--source menu` / `--source system` runs one route. Does not click the Dock. |
1617
| `core_application_exit_flutter_linux.py` | real Flutter secondary-window exit regression | Linux X11 / Wayland | No input; private Xvfb and Weston displays. Two realized secondary views exercise controller destruction, nativeapi close, GTK show/hide hooks, implicit-view preservation and normal process exit. Tests both listener removal and a manager that stays alive through process exit (18 checks per mode per backend). Flutter-only controls contain no nativeapi native assets; SDK diagnostics stay in the logs and any additional nativeapi warnings fail the test. Requires Flutter, clang, GTK3 development files, Xvfb, Weston and `dbus-run-session`. |
1718
| `core_window_close_flutter_macos.py` | Flutter window/controller ownership regression | macOS | Real FlutterEngine, multi-window controllers and bundled nativeapi Dart FFI: 19 checks for veto, explicit votes, native/public coalescing, host refusal, native close and Flutter-owned destruction, expired wrappers and handles. Test windows remain unshown and no input is sent. Requires Flutter, CMake and the matching debug framework; runs `window_close_flutter.dart`. |
1819
| `core_window_corner_preference_test.ps1` | core corner preference regression | Windows 11 | No-input test: four DWM policies, unchanged title bar/geometry/shadow, wrapper sharing, fullscreen/maximize restoration, invalid enums and stale handles; saves a comparison image for visual inspection. Override the test executable with `CORE_CORNER_TEST_EXE`. |
1920
| `core_window_content_protection_test.ps1` | core content protection regression | Windows | No-input test: native display affinity, shared wrappers, C ABI and actual capture exclusion/restoration of a pixel inside the test windows. Override the executable with `CORE_CONTENT_PROTECTION_TEST_EXE`. |
20-
| `core_window_title_bar_double_click_macos.py` | core title-bar double-click regression | macOS | Guarded real double clicks on bare hidden title bars: zoom/restore, Minimize, None and native Fill. Preferences are overridden only in the fixture process. Build `window_title_bar_double_click_gui_macos_test` in `core/build` first. |
21+
| `core_window_title_bar_double_click_macos.py` | core title-bar double-click regression | macOS | Guarded real double clicks on bare hidden title bars: zoom/restore (including the original frame), Minimize, None and native Fill measured against the screen's visible work area. Preferences are overridden only in the fixture process. Build `window_title_bar_double_click_gui_macos_test` in `core/build` first. |
2122
| `core_window_system_menu_test.ps1` | core system menu regression | Windows | Native popup states and guarded selections of custom, maximize, restore, minimize and close commands; content coordinates scale once. Override the executable with `CORE_SYSTEM_MENU_TEST_EXE`. |
2223
| `core_window_snap_layout_test.ps1` | core `window_maximize_button_test --serve` (#70) | Windows 11 | Real hover, no clicks: a hidden-title-bar window marks its own maximize button with `SetMaximizeButtonBounds`; resting the cursor there opens the snap layouts (another process's window appears below the button), the content window still receives the moves inside the button, and leaving closes the flyout and reaches the content as `WM_MOUSELEAVE`. Saves a screenshot. Override the executable with `CORE_SNAP_LAYOUT_TEST_EXE`. |
2324
| `flutter_window_title_bar_snap_test.ps1` | `window_title_bar_example` (#70) | Windows 11 | End to end in a real Flutter app: with the title bar Hidden the strip draws a Maximize / Restore chip wrapped in `MaximizeButtonArea`; resting the cursor on it opens the snap layouts and leaving closes them, a click maximizes (the chip reads Restore) and another restores to the earlier width. Owner-guarded clicks, no keyboard. Saves a screenshot. Set `TITLE_BAR_EXAMPLE_EXE` for a build outside `$RemoteWorkspace`. |
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Real macOS runner fixture for application_quit_gui_macos.py (#65).
2+
import 'dart:async';
3+
import 'dart:developer' as developer;
4+
import 'dart:ui' show AppExitResponse;
5+
6+
import 'package:flutter/widgets.dart';
7+
import 'package:nativeapi/nativeapi.dart' as native;
8+
9+
void main() => runApp(const QuitApp());
10+
11+
class QuitApp extends StatefulWidget {
12+
const QuitApp({super.key});
13+
14+
@override
15+
State<QuitApp> createState() => _QuitAppState();
16+
}
17+
18+
class _QuitAppState extends State<QuitApp> {
19+
bool _nativeAllows = false;
20+
bool _hostAllows = false;
21+
int _nativeRequests = 0;
22+
int _hostRequests = 0;
23+
late final int _listener;
24+
late final AppLifecycleListener _lifecycle;
25+
26+
@override
27+
void initState() {
28+
super.initState();
29+
final window = native.WindowManager.instance.getCurrent()!;
30+
window.title = 'NativeAPI quit verification';
31+
window.setSize(const native.Size(width: 640, height: 420), false);
32+
window.center();
33+
_lifecycle = AppLifecycleListener(
34+
onExitRequested: () async {
35+
setState(() => _hostRequests++);
36+
print('HOST_QUIT $_hostRequests allow=$_hostAllows');
37+
await Future<void>.delayed(const Duration(milliseconds: 100));
38+
return _hostAllows ? AppExitResponse.exit : AppExitResponse.cancel;
39+
},
40+
);
41+
_listener = native.Application.instance.addListener((event) async {
42+
if (event is! native.ApplicationQuitRequestedEvent) return;
43+
setState(() => _nativeRequests++);
44+
print('NATIVE_QUIT $_nativeRequests allow=$_nativeAllows');
45+
// Exercise the asynchronous Dart acknowledgement, not just a sync veto.
46+
await Future<void>.delayed(const Duration(milliseconds: 100));
47+
if (!_nativeAllows && event.request.isCancelable) event.request.cancel();
48+
});
49+
// Keep repeated regression runs independent of the user's mouse.
50+
developer.registerExtension('ext.nativeapi.quitMode', (
51+
method,
52+
parameters,
53+
) async {
54+
final mode = parameters['mode'];
55+
if (!const ['native', 'flutter', 'allow'].contains(mode)) {
56+
return developer.ServiceExtensionResponse.error(
57+
developer.ServiceExtensionResponse.invalidParams,
58+
'Expected native, flutter or allow',
59+
);
60+
}
61+
setState(() {
62+
_nativeAllows = mode != 'native';
63+
_hostAllows = mode == 'allow';
64+
});
65+
return developer.ServiceExtensionResponse.result('{}');
66+
});
67+
}
68+
69+
@override
70+
void dispose() {
71+
native.Application.instance.removeListener(_listener);
72+
_lifecycle.dispose();
73+
super.dispose();
74+
}
75+
76+
Widget _button(String title, VoidCallback action) => GestureDetector(
77+
behavior: HitTestBehavior.opaque,
78+
onTap: action,
79+
child: Padding(padding: const EdgeInsets.all(20), child: Text(title)),
80+
);
81+
82+
@override
83+
Widget build(BuildContext context) => Directionality(
84+
textDirection: TextDirection.ltr,
85+
child: ColoredBox(
86+
color: const Color(0xffeeeeee),
87+
child: DefaultTextStyle(
88+
style: const TextStyle(color: Color(0xff111111), fontSize: 18),
89+
child: Column(
90+
children: [
91+
const SizedBox(height: 30),
92+
const Text('NativeAPI quit verification'),
93+
Text('Native requests: $_nativeRequests'),
94+
Text('Host requests: $_hostRequests'),
95+
_button(
96+
'Native cancels',
97+
() => setState(() {
98+
_nativeAllows = false;
99+
_hostAllows = false;
100+
}),
101+
),
102+
_button(
103+
'Flutter cancels',
104+
() => setState(() {
105+
_nativeAllows = true;
106+
_hostAllows = false;
107+
}),
108+
),
109+
_button(
110+
'Allow quit',
111+
() => setState(() {
112+
_nativeAllows = true;
113+
_hostAllows = true;
114+
}),
115+
),
116+
],
117+
),
118+
),
119+
),
120+
);
121+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""Real Flutter/AppKit quit (#65): app-menu AXPress and OS normal termination.
3+
4+
No mouse or keyboard input by default; policy is set through the debug VM service.
5+
With --pointer, all pointer presses stay on the fixture's own window.
6+
The menu and OS driver validates both PID and executable before targeting it.
7+
Build the title-bar example with --target tools/gui/application_quit_flutter.dart
8+
(an absolute path), then run this script. The runner is the ordinary Flutter one.
9+
"""
10+
from pathlib import Path
11+
import argparse
12+
import subprocess
13+
import tempfile
14+
import time
15+
16+
from common import example
17+
from guiapp import Abort, Checks, assert_idle, pause
18+
19+
20+
def main():
21+
parser = argparse.ArgumentParser(description=__doc__)
22+
parser.add_argument('--source', choices=('menu', 'system'))
23+
parser.add_argument('--pointer', action='store_true', help='click policy buttons instead of using the VM service')
24+
args = parser.parse_args()
25+
checks = Checks()
26+
with tempfile.TemporaryDirectory(prefix='nativeapi-quit-driver-') as temporary:
27+
driver = str(Path(temporary) / 'quit-driver')
28+
subprocess.run(['clang', '-fobjc-arc', '-framework', 'Cocoa', '-framework',
29+
'ApplicationServices', str(Path(__file__).with_name(
30+
'application_quit_macos_driver.m')), '-o', driver], check=True)
31+
for source in ((args.source,) if args.source else ('menu', 'system')):
32+
if args.pointer:
33+
assert_idle()
34+
app = example('window_title_bar_example', args=['-ApplePersistenceIgnoreState', 'YES'])
35+
try:
36+
app.launch()
37+
38+
def counts():
39+
view = next(v for v in app.views() if v.has('NativeAPI quit verification'))
40+
values = {}
41+
for label in ('Native requests', 'Host requests'):
42+
line = next(t for t, _ in view.texts if t.startswith(label + ': '))
43+
values[label] = int(line.split(': ')[1])
44+
return values['Native requests'], values['Host requests']
45+
46+
def select(title):
47+
if not args.pointer:
48+
vm = app.probe
49+
isolate = next(i['id'] for i in vm._call('getVM')['result']['isolates']
50+
if i['name'] == 'main')
51+
mode = {'Native cancels': 'native', 'Flutter cancels': 'flutter',
52+
'Allow quit': 'allow'}[title]
53+
result = vm._call('ext.nativeapi.quitMode', isolateId=isolate, mode=mode)
54+
if 'error' in result:
55+
raise Abort(f'cannot set fixture mode: {result}')
56+
pause(0.3)
57+
return
58+
_, frame = app.windows()[0]
59+
view = next(v for v in app.views() if v.has(title))
60+
app.click(app.to_screen(frame, view, view.center(title)))
61+
pause(0.3)
62+
63+
def request():
64+
subprocess.run([driver, str(app.proc.pid), app.executable, source],
65+
check=True, timeout=10)
66+
67+
def expect(native, host):
68+
deadline = time.monotonic() + 8
69+
actual = None
70+
while time.monotonic() < deadline and app.proc.poll() is None:
71+
actual = counts()
72+
if actual == (native, host):
73+
break
74+
pause(0.2)
75+
# Give the asynchronous veto/host reply time to finish too.
76+
pause(0.5)
77+
alive = app.proc.poll() is None and len(app.windows()) == 1
78+
checks.check(f'{source}: veto keeps the app and window alive', alive)
79+
if not alive:
80+
raise Abort('quit bypassed cancellation')
81+
checks.check(f'{source}: exactly {native} native and {host} host requests',
82+
counts() == (native, host), counts())
83+
84+
checks.check(f'{source}: counters start at zero', counts() == (0, 0))
85+
request()
86+
expect(1, 0)
87+
request()
88+
expect(2, 0)
89+
select('Flutter cancels')
90+
request()
91+
expect(3, 1)
92+
request()
93+
expect(4, 2)
94+
select('Allow quit')
95+
request()
96+
try:
97+
app.proc.wait(timeout=10)
98+
except subprocess.TimeoutExpired:
99+
pass
100+
checks.check(f'{source}: approval exits normally', app.proc.poll() == 0,
101+
app.proc.poll())
102+
output = app.output()
103+
checks.check(f'{source}: final native request is delivered once',
104+
output.count('NATIVE_QUIT 5 allow=true') == 1)
105+
checks.check(f'{source}: final Flutter approval is delivered once',
106+
output.count('HOST_QUIT 3 allow=true') == 1)
107+
except (Abort, subprocess.SubprocessError, StopIteration) as error:
108+
checks.check(f'{source}: scenario finishes', False, error)
109+
finally:
110+
app.quit()
111+
print(f'{source} log: {app.log_path}', flush=True)
112+
print(f'{checks.failures} failure(s)', flush=True)
113+
return int(bool(checks.failures))
114+
115+
116+
if __name__ == '__main__':
117+
raise SystemExit(main())

0 commit comments

Comments
 (0)