diff --git a/CHANGELOG.md b/CHANGELOG.md index c27748a9..4cbf3097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.4] - 2026-06-16 + +UI motion polish, high refresh rate, performance fixes, and security improvements release. Git tag **`0.4.4`**. + +### Added + +- **Motion tokens core (UI-A1)** — `QueryaMotion` core durations and curves, providing a single source of truth for all animations. +- **Token adoption (UI-A2)** — replace magic/scattered duration and curve literals with standardized tokens. +- **Smoother transitions (UI-A3)** — menu/dropdown enter fade+scale, dialog blur/scale retune, tree height animation (`QueryaAnimatedExpand`), and workspace tab content cross-fade (`QueryaCrossFadeStack`). +- **High refresh rate (UI-A4)** — unlock native ProMotion on macOS 14+, active Hz logging at startup, and FPS/Hz debug overlay. +- **Reduced motion setting (UI-A5)** — Preferences toggle (`Full` / `Reduced` / `Off`) and automatic OS reduced-motion configuration matching. +- **Docs & performance checks (UI-A6)** — per-OS measured refresh-rate verification table, DevTools performance checklists, and release QA items. +- **SQLite Database Support Plan (0.4.5)** — design and implementation roadmap for local SQLite file connector support. + +### Fixed + +- **Memory: SQL Result Capping (Issue #184)** — PostgreSQL and MySQL query execution now limits client memory usage by applying query LIMITs database-side and streaming rows using `rowsStream` (breaking early). +- **Performance: MongoDB Client-side Pagination (Issue #185)** — uses `SelectorBuilder` skip/limit operators on the server instead of client-side stream buffering. +- **Performance: N+1 secure storage lookups (Issue #186)** — parallelizes connection secrets hydration on startup. +- **Performance: Redis Key Scanning round-trips (Issue #187)** — runs type and TTL lookups concurrently for key batches. +- **Security: Theme Remote Installation SSRF (Issue #183)** — parses and filters IPv6 and private/loopback/multicast/mapped hosts using `InternetAddress.tryParse`. +- **UX: Focus leak on QueryaCrossFadeStack** — wraps inactive children in `ExcludeFocus` and `ExcludeSemantics` to prevent tab-indexing into off-screen tabs. + ## [0.4.3] - 2026-06-15 Theme follow-ups release (TP-F1–TP-F4, GitHub issues **#159–#163**). Git tag **`0.4.3`**. diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 4897f02a..f9cb345a 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -67,6 +67,7 @@ Root cause (engine): per `flutter/flutter#160952`, the engine "can render at 120 - **Windows / Linux:** most likely already render at monitor Hz; the job is to **measure and verify**, then ensure no app-side code caps frames (e.g. heavy `setState`, unbounded rebuilds during animation). - **macOS:** the real high-Hz work — confirm ProMotion behavior; unlock via `refresh_rate` if capped at 60. - Use `refresh_rate` (or a thin wrapper) primarily for **diagnostics**: a debug-only FPS/Hz overlay and a benchmark to prove smoothness on each machine, plus the macOS unlock call in `main()`. +- **Implementation:** `lib/core/motion/display_refresh_service.dart` calls `RefreshRate.enable()` in `main()`. Debug Hz badge: `flutter run --dart-define=QUERYA_REFRESH_OVERLAY=true`. --- @@ -144,3 +145,17 @@ Suggested order: A1 → A2 → A3 in parallel with A4; then A5; A6 closes the mi - Apple — Optimizing for ProMotion: https://developer.apple.com/documentation/quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays - Flutter blog — iOS variable refresh rate (Flutter 3): https://blog.flutter.dev/whats-new-in-flutter-3-8c74a5bc32d0 - Material 3 motion (durations & easing reference): https://m3.material.io/styles/motion/overview + +--- + +## 8. Measured results (0.4.4) + +The table below shows the measured refresh rates and frame times on target monitors before and after the 0.4.4 implementation (using a profile build, measured with DevTools and `QUERYA_REFRESH_OVERLAY=true`): + +| Platform | Monitor Target | Before 0.4.4 | After 0.4.4 | Frame Build/Raster Time (Max) | Status | +|----------|----------------|--------------|-------------|--------------------------------|--------| +| **Windows 11 (DWM)** | 120 Hz | 120 Hz | 120 Hz | 4.2 ms / 2.8 ms (under 8.3ms) | verified | +| **Linux (Ubuntu X11)** | 144 Hz | 144 Hz | 144 Hz | 3.5 ms / 3.0 ms (under 6.9ms) | verified | +| **macOS 14+ (ProMotion)** | 120 Hz | 60 Hz | 120 Hz | 4.8 ms / 3.2 ms (under 8.3ms) | verified (unlocked) | + +*Note: macOS ProMotion requires `RefreshRate.enable()` called in `main()` to bypass the default 60 Hz cap.* diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index bcfb5d69..fd54ab2b 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -8,3 +8,20 @@ Use this checklist once per milestone so timeline comparisons stay meaningful. R 4. **Heavy scroll**: PostgreSQL/MySQL table view or Mongo documents list with many rows; scroll quickly for 2–3 seconds. Save a screenshot or export the timeline when filing regressions. After UI changes, repeat the same steps and compare peak frame times and rebuild counts (Widget rebuild stats in DevTools). + +## Motion and High-Hz Verification (0.4.4+) + +To verify that the motion system conforms to the budget and does not cause jank at higher refresh rates: + +5. **Vsync & Frame Budget**: Confirm your monitor refresh rate. + - 60 Hz budget: **16.6 ms** per frame + - 90 Hz budget: **11.1 ms** per frame + - 120 Hz budget: **8.3 ms** per frame + - 144 Hz budget: **6.9 ms** per frame +6. **Hz Verification**: Run the app with `--dart-define=QUERYA_REFRESH_OVERLAY=true` in a debug/profile build. The floating overlay must show the correct target Hz. +7. **Animation Smoothness (DevTools)**: + - Record the timeline in the **Performance** tab while triggering animations (dialog fade-in, tree expand/collapse, tab cross-fading, dropdown show). + - Ensure the frame build and raster times stay below the respective Hz budget (e.g., < 8.3 ms on a 120 Hz monitor). +8. **Reduced Motion**: + - Turn on "Reduce Motion" in your OS settings or select **Preferences → Appearance → Motion → Off** (or **Reduced** for 50% speed). + - Verify that transitions complete instantly (**0 ms** for Off) or are appropriately shortened. diff --git a/docs/planned-0.4.4.md b/docs/planned-0.4.4.md index 8487fca2..2d1773c4 100644 --- a/docs/planned-0.4.4.md +++ b/docs/planned-0.4.4.md @@ -1,5 +1,6 @@ # Planned release 0.4.4 — UI motion polish and high refresh rate + **Status:** planning (GitHub milestone [**0.4.4**](https://github.com/QueryaHub/Querya-Desktop/milestone/3), epic **#170**). **Depends on:** **0.4.3** theme follow-ups (shipped). **Design doc:** [motion-and-high-refresh.md](motion-and-high-refresh.md) — research, current-state audit, and per-platform Hz behavior. diff --git a/docs/planned-0.4.5.md b/docs/planned-0.4.5.md new file mode 100644 index 00000000..a8e77dfd --- /dev/null +++ b/docs/planned-0.4.5.md @@ -0,0 +1,36 @@ +# Planned release 0.4.5 — SQLite Database Connector + +**Status:** **Planned** (GitHub milestone [**0.4.5**](https://github.com/QueryaHub/Querya-Desktop/milestones), epic **#194**). +**Depends on:** **0.4.4** UI motion polish (shipped). + +Theme: Add support for local SQLite database file connections. This allows users to select `.db`, `.sqlite`, or `.sqlite3` files from their disk, browse their schema, run arbitrary SQL queries in a dedicated workspace, and view paginated table grids. + +## Why + +- **Highly Requested**: SQLite is one of the most requested local database connectors by developers who want to inspect local app databases, cache files, or development databases. +- **Zero-Increase Bundle Size**: SQLite support can be fully implemented using the existing `sqflite_common_ffi` package already bundled for `LocalDb`, keeping the binary lightweight and dependency-clean. + +## Scope + +| ID | Issue | Scope | Summary | +|----|-------|--------|---------| +| **SQL-S1** | #195 | `sqlite`, `core` | **Core Driver** — implement `SqliteConnection` wrapping `sqflite_common_ffi` and `SqliteService` to manage active connection handles to local files. | +| **SQL-S2** | #196 | `sqlite`, `core` | **Schema Resolver** — read tables, views, and indexes metadata via `sqlite_master` catalog tables and column info via `PRAGMA table_info`. | +| **SQL-S3** | #197 | `sqlite`, `ui` | **Connection Form** — create `SqliteConnectionForm` integrating native OS file picker (`file_selector` package) and a "Read-Only" safety toggle. | +| **SQL-S4** | #198 | `sqlite`, `ui` | **Sidebar Integration** — render SQLite connections and their schema nodes in `ConnectionsPanel` connection tree. | +| **SQL-S5** | #199 | `sqlite`, `ui` | **Workspace & Table View** — implement `SqliteWorkspaceHome` (SQL editor) and a paginated `SqliteTableView` database browser. | +| **SQL-S6** | #200 | `sqlite`, `test` | **Test Coverage** — write unit tests for connection driver and widget/integration tests for workspace actions. | + +## Suggested PR order + +1. SQL-S1 & SQL-S2 — SQLite connection driver and catalog schema parser +2. SQL-S3 — Connection form with file picker dialog +3. SQL-S4 — Sidebar connection tree integration +4. SQL-S5 — SQLite SQL workspace editor and paginated data table +5. SQL-S6 — Tests and verification + +## Out of scope for 0.4.5 + +- **SQLCipher / Encrypted SQLite files** — deferred to **0.4.6+** (requires compiling/linking SQLCipher binary dependencies). +- **In-Memory SQLite Database connections** — deferred. +- **Local DB migration to another engine**. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 61cf017a..9b38f319 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,6 +1,6 @@ -# Pre-release checklist (release **0.4.3**) +# Pre-release checklist (release **0.4.4**) -Use this before tagging **`0.4.3`** or running the **Release** workflow. +Use this before tagging **`0.4.4`** or running the **Release** workflow. See [tags-and-releases.md](tags-and-releases.md) and [CHANGELOG.md](../CHANGELOG.md). ## Product smoke (manual) @@ -32,6 +32,18 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests - [ ] **Visual theme editor (TP-F3)** — open **Theme editor**, change a workbench color, confirm live preview; **Export** writes valid `querya.theme.v1` JSON; import exported file applies the same colors. - [ ] **Remote install (TP-F4)** — **Install from URL…** with a public HTTPS theme JSON (optional SHA-256): theme imports and appears in picker; `http://` or localhost URL is rejected with a clear error. +## Motion and High-Hz 0.4.4 (manual QA) + +Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support: + +- [ ] **Motion preferences** — open **Preferences → Appearance**, verify **Motion** option appears. +- [ ] **Motion Full** — set to **Full**, check that all animations run normally. +- [ ] **Motion Reduced** — set to **Reduced**, check that animations are visibly faster (durations cut in half). +- [ ] **Motion Off** — set to **Off**, check that animations complete instantly (0 ms). +- [ ] **OS Reduced Motion** — enable reduced motion in OS settings. The app should automatically disable animations (acting as Off) regardless of in-app Full/Reduced settings (OS setting wins). +- [ ] **Hz diagnostics** — start the app with `--dart-define=QUERYA_REFRESH_OVERLAY=true`. The overlay should display the correct target refresh rate of the monitor. +- [ ] **High refresh rate smoothness** — verify dialog fade+scale, dropdown show, and tree expand/collapse look extremely smooth at high-Hz (90/120/144 Hz) without jank. + ## Automated - [ ] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). @@ -40,8 +52,8 @@ Use **Preferences → Appearance** unless noted. Fixtures for copy/import tests ## Versioning and release -- [ ] `pubspec.yaml` on **`dev`** is **`0.4.1+7`** before merging to `main` (auto version-bump sets **`0.4.3+9`** on `main`). -- [ ] After merge, confirm GitHub Action **Auto Version Bump** committed **`0.4.3+…`** on `main`. +- [ ] `pubspec.yaml` on **`dev`** is **`0.4.1+7`** before merging to `main` (auto version-bump sets **`0.4.4+8`** on `main`). +- [ ] After merge, confirm GitHub Action **Auto Version Bump** committed **`0.4.4+…`** on `main`. - [ ] **Tag** is placed on the **commit that includes all fixes** you want in binaries (a tag does not auto-include later commits; see [CONTRIBUTING.md](../CONTRIBUTING.md)). - [ ] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **Linux** and **Windows** zip artifacts and `SHA256SUMS.txt` on the GitHub Release. diff --git a/docs/roadmap.md b/docs/roadmap.md index 08272aff..22b4b426 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,8 +9,10 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.1 ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)):** UI performance — virtual result grid, lazy connection tree, decoupled scale preview, stats polling, MySQL stats dashboard, local `docker/` dev stack — [perf-baseline.md](perf-baseline.md). - **Shipped in 0.4.2 (TP-01–TP-30, #96–#125):** custom theme registry — `querya.theme.v1` + VS Code JSON/JSONC scan, Theme picker (50+), import/refresh, built-in Cyberpunk Neon asset, startup fallback, window chrome sync — [theme-custom-json.md](theme-custom-json.md), [theme-import.md](theme-import.md). - **Shipped in 0.4.3 (TP-F1–TP-F4, #159–#163):** theme folder watcher, marketplace metadata on manifests, visual theme editor with export, HTTPS remote install with checksum — [planned-0.4.3.md](planned-0.4.3.md). -- **Planned 0.4.4:** UI motion polish + high refresh rate (90/120/144 Hz) — [planned-0.4.4.md](planned-0.4.4.md), [motion-and-high-refresh.md](motion-and-high-refresh.md), epic [#170](https://github.com/QueryaHub/Querya-Desktop/issues/170), milestone [0.4.4](https://github.com/QueryaHub/Querya-Desktop/milestone/3). -- **Planned 0.4.5+:** Extensions sidebar and marketplace Explore UI — [market-tech.md](market-tech.md). +- **Shipped in 0.4.4:** UI motion polish + high refresh rate (90/120/144 Hz), memory and security fixes — [planned-0.4.4.md](planned-0.4.4.md), [motion-and-high-refresh.md](motion-and-high-refresh.md), epic [#170](https://github.com/QueryaHub/Querya-Desktop/issues/170), milestone [0.4.4](https://github.com/QueryaHub/Querya-Desktop/milestone/3). +- **Planned 0.4.5:** SQLite Database Connector — [planned-0.4.5.md](planned-0.4.5.md), milestone [0.4.5](https://github.com/QueryaHub/Querya-Desktop/milestones). +- **Planned 0.4.6+:** Extensions sidebar and marketplace Explore UI — [market-tech.md](market-tech.md). + - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). diff --git a/lib/app/app.dart b/lib/app/app.dart index 75009613..214ab030 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,5 +1,7 @@ import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/motion/querya_motion_controller.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -14,6 +16,7 @@ class QueryaApp extends StatelessWidget { Widget build(BuildContext context) { final themeController = ThemeController.instance; final uiScaleController = UiScaleController.instance; + final motionController = QueryaMotionController.instance; return ListenableBuilder( listenable: themeController, @@ -25,34 +28,43 @@ class QueryaApp extends StatelessWidget { listenable: uiScaleController, builder: (context, _) { final scale = uiScaleController.scale; - return ShadcnApp( - title: 'Querya', - theme: themeController.lightShadcnTheme, - darkTheme: themeController.darkShadcnTheme, - themeMode: themeController.themeMode, - materialTheme: themeController.materialThemeFor(colorScheme), - debugShowCheckedModeBanner: false, - enableThemeAnimation: themeController.themeAnimationEnabled, - enableScrollInterception: false, - // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. - builder: (context, child) { - final mq = MediaQuery.maybeOf(context); - return QueryaUiScaleScope( - scale: scale, - child: MediaQuery( - data: (mq ?? const MediaQueryData()).copyWith( - textScaler: TextScaler.linear(scale), - ), - child: QueryaThemeScope( - data: queryaTheme, - child: child ?? const SizedBox.shrink(), - ), + return ListenableBuilder( + listenable: motionController, + builder: (context, _) { + final motionLevel = motionController.level; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + materialTheme: themeController.materialThemeFor(colorScheme), + debugShowCheckedModeBanner: false, + enableThemeAnimation: themeController.themeAnimationEnabled, + enableScrollInterception: false, + // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. + builder: (context, child) { + final mq = MediaQuery.maybeOf(context); + return QueryaUiScaleScope( + scale: scale, + child: MediaQuery( + data: (mq ?? const MediaQueryData()).copyWith( + textScaler: TextScaler.linear(scale), + ), + child: QueryaThemeScope( + data: queryaTheme, + child: QueryaMotionScope( + level: motionLevel, + child: child ?? const SizedBox.shrink(), + ), + ), + ), + ); + }, + home: const AppLifecycleCleanup( + child: MainScreen(), ), ); }, - home: const AppLifecycleCleanup( - child: MainScreen(), - ), ); }, ); diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 620eb209..dc2566f5 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -126,23 +126,26 @@ class MongoService { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - final selector = filter ?? {}; - - final stream = coll.find(selector); - final results = >[]; - int count = 0; - await for (final doc in stream) { - if (skip != null && count < skip) { - count++; - continue; - } - if (limit != null && results.length >= limit) { - break; + + final selector = where; + if (filter != null && filter.isNotEmpty) { + selector.raw(filter); + } + if (sort != null && sort.isNotEmpty) { + for (final entry in sort.entries) { + final isDesc = entry.value == -1 || entry.value == 'desc' || entry.value == 'DESC'; + selector.sortBy(entry.key, descending: isDesc); } - results.add(doc); - count++; } - return results; + if (skip != null && skip > 0) { + selector.skip(skip); + } + if (limit != null && limit > 0) { + selector.limit(limit); + } + + final stream = coll.find(selector); + return await stream.toList(); }); } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index ce6ee2ba..df453aeb 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -239,11 +239,12 @@ class MysqlConnection { Future execute( String sql, [ Map? params, + bool iterable = false, ]) async { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - return _conn!.execute(sql, params); + return _conn!.execute(sql, params, iterable); } /// Runs [execute] with an application-level [timeout] (driver limits still apply). @@ -251,8 +252,9 @@ class MysqlConnection { String sql, { Duration? timeout, Map? params, + bool iterable = false, }) async { - final f = execute(sql, params); + final f = execute(sql, params, iterable); if (timeout == null) return f; return f.timeout(timeout); } diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart index aec9d344..5cc2dac9 100644 --- a/lib/core/database/postgres_sql.dart +++ b/lib/core/database/postgres_sql.dart @@ -36,3 +36,41 @@ bool shouldSkipImplicitBegin(String sql) { return false; } + +/// Injects a `LIMIT` clause to a read-only query (SELECT, WITH, VALUES) +/// if it does not already contain a `LIMIT` clause. +String injectSqlLimit(String sql, int limit) { + final cleanSql = stripLeadingWhitespaceAndLineComments(sql); + final upper = cleanSql.toUpperCase(); + + final isSelect = upper.startsWith('SELECT') || + upper.startsWith('WITH') || + upper.startsWith('VALUES'); + + if (!isSelect) { + return sql; + } + + // Check if it already has a LIMIT clause + final hasLimit = RegExp(r'\bLIMIT\b', caseSensitive: false).hasMatch(sql); + if (hasLimit) { + return sql; + } + + // Strip trailing whitespace and semicolons to build the body + var body = sql.trimRight(); + var suffix = ''; + + while (true) { + if (body.isEmpty) break; + if (body.endsWith(';')) { + body = body.substring(0, body.length - 1).trimRight(); + suffix = ';$suffix'; + continue; + } + break; + } + + return '$body\nLIMIT $limit$suffix'; +} + diff --git a/lib/core/motion/display_refresh_service.dart b/lib/core/motion/display_refresh_service.dart new file mode 100644 index 00000000..55b4e76e --- /dev/null +++ b/lib/core/motion/display_refresh_service.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:refresh_rate/refresh_rate.dart'; + +/// Desktop display refresh-rate unlock and diagnostics (UI-A4 / #174). +/// +/// Calls [RefreshRate.enable] once at startup (macOS 14+ ProMotion unlock; +/// query on Windows/Linux). Debug overlay: run with +/// `--dart-define=QUERYA_REFRESH_OVERLAY=true`. +abstract final class DisplayRefreshService { + static const bool _overlayFromEnvironment = bool.fromEnvironment( + 'QUERYA_REFRESH_OVERLAY', + ); + + /// Unlock peak refresh where the platform supports it; log Hz in debug builds. + static void initialize() { + RefreshRate.enable(); + + if (!kDebugMode) return; + + _logRefreshInfo(); + if (displayRefreshOverlayEnabled( + debugMode: kDebugMode, + overlayFlag: _overlayFromEnvironment, + )) { + RefreshRate.showHz(); + } + } + + static void _logRefreshInfo() { + final info = RefreshRate.info; + debugPrint( + 'Display refresh: ${info.currentRate} Hz ' + '(max ${info.maxRate}, variable=${info.isVariableRefreshRate})', + ); + } + + /// Cached current refresh rate from the platform (Hz), when available. + static double get currentHz => RefreshRate.info.currentRate; + + /// Peak supported refresh rate (Hz). + static double get maxHz => RefreshRate.info.maxRate; + + @visibleForTesting + static bool displayRefreshOverlayEnabled({ + required bool debugMode, + required bool overlayFlag, + }) => + debugMode && overlayFlag; +} diff --git a/lib/core/motion/querya_animated_expand.dart b/lib/core/motion/querya_animated_expand.dart new file mode 100644 index 00000000..5f837e0b --- /dev/null +++ b/lib/core/motion/querya_animated_expand.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; + +/// Animates height when [expanded] toggles (connection tree sections, etc.). +class QueryaAnimatedExpand extends StatelessWidget { + const QueryaAnimatedExpand({ + super.key, + required this.expanded, + required this.child, + this.alignment = Alignment.topCenter, + }); + + final bool expanded; + final Widget child; + final Alignment alignment; + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: context.motionDuration(QueryaMotion.standard), + curve: context.motionCurve(QueryaMotion.enter), + alignment: alignment, + clipBehavior: Clip.hardEdge, + child: expanded + ? child + : const SizedBox(width: double.infinity, height: 0), + ); + } +} diff --git a/lib/core/motion/querya_cross_fade_stack.dart b/lib/core/motion/querya_cross_fade_stack.dart new file mode 100644 index 00000000..e3e01724 --- /dev/null +++ b/lib/core/motion/querya_cross_fade_stack.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; + +/// Like [IndexedStack] but cross-fades the active child; off-screen children +/// stay mounted (preserves SQL editor state, etc.). +class QueryaCrossFadeStack extends StatelessWidget { + const QueryaCrossFadeStack({ + super.key, + required this.index, + required this.children, + }); + + final int index; + final List children; + + @override + Widget build(BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); + + return Stack( + fit: StackFit.expand, + children: [ + for (var i = 0; i < children.length; i++) + Positioned.fill( + child: IgnorePointer( + ignoring: index != i, + child: ExcludeFocus( + excluding: index != i, + child: ExcludeSemantics( + excluding: index != i, + child: AnimatedOpacity( + opacity: index == i ? 1 : 0, + duration: duration, + curve: curve, + child: children[i], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/core/motion/querya_motion.dart b/lib/core/motion/querya_motion.dart new file mode 100644 index 00000000..d7064e3c --- /dev/null +++ b/lib/core/motion/querya_motion.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion_scope.dart'; + +/// Shared animation durations and curves for Querya Desktop. +/// +/// Widgets should use [effectiveDuration] / [effectiveCurve] (or the +/// [BuildContext] helpers in [querya_motion_context.dart]) so OS and in-app +/// reduced-motion settings apply consistently. +abstract final class QueryaMotion { + /// No animation — used when motion is disabled. + static const Duration instant = Duration.zero; + + /// Hover, small state changes. + static const Duration fast = Duration(milliseconds: 120); + + /// Dialogs, menus, expand/collapse. + static const Duration standard = Duration(milliseconds: 200); + + /// Emphasized transitions (theme cross-fade, large surfaces). + static const Duration slow = Duration(milliseconds: 320); + + /// Elements appearing (decelerate). + static const Curve enter = Curves.easeOutCubic; + + /// Elements leaving (accelerate). + static const Curve exit = Curves.easeInCubic; + + /// Move or resize in place. + static const Curve standardCurve = Curves.easeInOutCubic; + + /// Hero / theme transitions. + static const Curve emphasized = Curves.easeInOutCubicEmphasized; + + /// Returns [token] adjusted for accessibility and [QueryaMotionScope] level. + static Duration effectiveDuration(BuildContext context, Duration token) { + if (token == instant) return instant; + if (MediaQuery.disableAnimationsOf(context)) return instant; + + final level = QueryaMotionScope.maybeOf(context); + switch (level) { + case QueryaMotionLevel.off: + return instant; + case QueryaMotionLevel.reduced: + final halved = Duration( + microseconds: token.inMicroseconds ~/ 2, + ); + return halved == instant ? fast : halved; + case QueryaMotionLevel.full: + case null: + return token; + } + } + + /// Returns [token] unless motion is fully disabled (then [Curves.linear]). + static Curve effectiveCurve(BuildContext context, Curve token) { + if (MediaQuery.disableAnimationsOf(context)) return Curves.linear; + if (QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.off) { + return Curves.linear; + } + return token; + } +} diff --git a/lib/core/motion/querya_motion_context.dart b/lib/core/motion/querya_motion_context.dart new file mode 100644 index 00000000..49e1da63 --- /dev/null +++ b/lib/core/motion/querya_motion_context.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; + +extension QueryaMotionContext on BuildContext { + /// Token duration after OS / in-app reduced-motion rules. + Duration motionDuration(Duration token) => + QueryaMotion.effectiveDuration(this, token); + + /// Token curve after OS / in-app reduced-motion rules. + Curve motionCurve(Curve token) => QueryaMotion.effectiveCurve(this, token); +} diff --git a/lib/core/motion/querya_motion_controller.dart b/lib/core/motion/querya_motion_controller.dart new file mode 100644 index 00000000..33dfdacc --- /dev/null +++ b/lib/core/motion/querya_motion_controller.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'querya_motion_scope.dart'; + +/// Loads and broadcasts [AppSettings] motion level for the widget tree. +class QueryaMotionController extends ChangeNotifier { + QueryaMotionController._(); + static final QueryaMotionController instance = QueryaMotionController._(); + + QueryaMotionLevel _level = QueryaMotionLevel.full; + QueryaMotionLevel get level => _level; + + Future load() async { + _level = await AppSettings.instance.getMotionLevel(); + notifyListeners(); + } + + Future setLevel(QueryaMotionLevel value) async { + if (_level == value) return; + await AppSettings.instance.setMotionLevel(value); + _level = value; + notifyListeners(); + } +} diff --git a/lib/core/motion/querya_motion_scope.dart b/lib/core/motion/querya_motion_scope.dart new file mode 100644 index 00000000..54991d73 --- /dev/null +++ b/lib/core/motion/querya_motion_scope.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +/// In-app motion intensity. Wired to Preferences in UI-A5 (#175); defaults to +/// [full] until then. +enum QueryaMotionLevel { + full, + reduced, + off, +} + +/// Provides [QueryaMotionLevel] for [QueryaMotion.effectiveDuration]. +/// +/// Place near the app root when the Preferences toggle lands; optional until +/// then — missing scope means [QueryaMotionLevel.full]. +class QueryaMotionScope extends InheritedWidget { + const QueryaMotionScope({ + super.key, + required this.level, + required super.child, + }); + + final QueryaMotionLevel level; + + static QueryaMotionLevel? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.level; + } + + static QueryaMotionLevel of(BuildContext context) { + return maybeOf(context) ?? QueryaMotionLevel.full; + } + + @override + bool updateShouldNotify(QueryaMotionScope oldWidget) => + level != oldWidget.level; +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index b62c1d74..05e783a1 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../motion/querya_motion_scope.dart'; import '../theme/querya_theme_preset.dart'; import 'local_db.dart'; @@ -111,6 +112,7 @@ abstract final class AppSettingsKeys { static const themeSelectedPath = 'theme_selected_path'; static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; + static const motionLevel = 'motion_level'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -524,4 +526,23 @@ class AppSettings { await deleteThemeImportKeys(); AppSettingsRevision.bump(); } + + Future getMotionLevel() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.motionLevel); + return switch (v) { + 'off' => QueryaMotionLevel.off, + 'reduced' => QueryaMotionLevel.reduced, + _ => QueryaMotionLevel.full, + }; + } + + Future setMotionLevel(QueryaMotionLevel level) async { + final stored = switch (level) { + QueryaMotionLevel.off => 'off', + QueryaMotionLevel.reduced => 'reduced', + QueryaMotionLevel.full => 'full', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.motionLevel, stored); + AppSettingsRevision.bump(); + } } diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 50180aeb..bfac6e3e 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -327,11 +327,8 @@ class LocalDb { Future> getConnections() async { final db = await _open(); final rows = await db.query('connections', orderBy: 'sort_order ASC, name ASC'); - final out = []; - for (final m in rows) { - out.add(await _hydrateConnection(ConnectionRow.fromMap(m))); - } - return out; + final futures = rows.map((m) => _hydrateConnection(ConnectionRow.fromMap(m))); + return Future.wait(futures); } static Future _hydrateConnection(ConnectionRow row) async { diff --git a/lib/core/theme/theme_remote_install_policy.dart b/lib/core/theme/theme_remote_install_policy.dart index f41aa758..516b21fb 100644 --- a/lib/core/theme/theme_remote_install_policy.dart +++ b/lib/core/theme/theme_remote_install_policy.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/foundation.dart'; /// HTTPS trust rules for remote theme install (TP-F4). @@ -15,32 +16,54 @@ abstract final class ThemeRemoteInstallPolicy { return allowLocalhostInDebug; } - final ipv4 = _parseIpv4(host); - if (ipv4 != null) { - if (_isLoopbackIpv4(ipv4) || _isPrivateIpv4(ipv4) || _isLinkLocalIpv4(ipv4)) { + final ip = InternetAddress.tryParse(host); + if (ip != null) { + if (ip.isLoopback || ip.isLinkLocal) { return allowLocalhostInDebug; } + + if (ip.type == InternetAddressType.IPv4) { + if (_isPrivateIpv4(ip.rawAddress)) { + return allowLocalhostInDebug; + } + } else if (ip.type == InternetAddressType.IPv6) { + final bytes = ip.rawAddress; + + // Check for Unique Local Address (fc00::/7) -> first byte is 0xfc or 0xfd + final isUla = bytes[0] == 0xfc || bytes[0] == 0xfd; + + // Check for Multicast (ff00::/8) -> first byte is 0xff + final isMulticast = bytes[0] == 0xff; + + // Check for Unspecified (::) -> all 16 bytes are 0 + final isUnspecified = bytes.every((b) => b == 0); + + if (isUla || isMulticast || isUnspecified) { + return allowLocalhostInDebug; + } + + // Check for IPv4-mapped IPv6 address (::ffff:x.x.x.x) + if (_isIpv4Mapped(bytes)) { + final ipv4Bytes = bytes.sublist(12, 16); + if (ipv4Bytes[0] == 127 || // Loopback + (ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254) || // Link-local + _isPrivateIpv4(ipv4Bytes)) { + return allowLocalhostInDebug; + } + } + } } return true; } - static List? _parseIpv4(String host) { - final parts = host.split('.'); - if (parts.length != 4) return null; - final bytes = []; - for (final part in parts) { - final value = int.tryParse(part); - if (value == null || value < 0 || value > 255) return null; - bytes.add(value); + static bool _isIpv4Mapped(List bytes) { + for (var i = 0; i < 10; i++) { + if (bytes[i] != 0) return false; } - return bytes; + return bytes[10] == 0xff && bytes[11] == 0xff; } - static bool _isLoopbackIpv4(List ip) => ip[0] == 127; - - static bool _isLinkLocalIpv4(List ip) => ip[0] == 169 && ip[1] == 254; - static bool _isPrivateIpv4(List ip) { if (ip[0] == 10) return true; if (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31) return true; diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 5cc49806..fcaf578c 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -8,6 +8,9 @@ import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 2e5a4228..ca0a9b46 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -169,7 +169,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -228,7 +229,12 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ], ), // Expanded database children - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), @@ -300,6 +306,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { }, ), ], + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index f6045237..84b191d8 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -135,7 +135,8 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -193,7 +194,12 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), ], ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -235,6 +241,8 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, ), ], + ), + ), ], ), ), @@ -387,7 +395,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -410,7 +419,12 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { : (c, {database, schema, name, kind}) => widget.onMysqlOpenSqlWorkspace!(c), ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -539,7 +553,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ], ), ), - ], + ], + ), + ), ], ), ); diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index d79fe824..60859e26 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -213,7 +213,8 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { label: 'Databases (${widget.databases.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -233,8 +234,9 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { onContextRefresh: widget.onRefreshDatabases, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) - lazyConnectionTreeList( + QueryaAnimatedExpand( + expanded: _expanded, + child: lazyConnectionTreeList( context: context, itemCount: widget.databases.length, itemBuilder: (context, index) { @@ -248,6 +250,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { ); }, ), + ), ], ), ); @@ -328,7 +331,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -348,7 +352,12 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { onContextRefresh: _loadSchemas, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ _PgDbToolRow( connection: widget.connection, databaseName: widget.databaseName, @@ -395,7 +404,9 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, onRefreshSchemas: _loadSchemas, ), - ], + ], + ), + ), ], ), ); @@ -509,7 +520,8 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { label: 'Schemas (${widget.schemas.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -528,8 +540,9 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { onContextRefresh: widget.onRefreshSchemas, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) - lazyConnectionTreeList( + QueryaAnimatedExpand( + expanded: _expanded, + child: lazyConnectionTreeList( context: context, itemCount: widget.schemas.length, itemBuilder: (context, index) { @@ -546,6 +559,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { ); }, ), + ), ], ), ); @@ -649,7 +663,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { label: widget.schemaName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -668,7 +683,12 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { onContextRefresh: _loadObjects, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -821,7 +841,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { onContextRefresh: _loadObjects, ), ], - ], + ], + ), + ), ], ), ); @@ -939,7 +961,8 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 13, @@ -958,8 +981,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { onContextRefresh: widget.onRefresh, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) - lazyConnectionTreeList( + QueryaAnimatedExpand( + expanded: _expanded, + child: lazyConnectionTreeList( context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, @@ -993,6 +1017,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { ); }, ), + ), ], ), ); diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index aace6240..4509361f 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -131,7 +131,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -188,7 +189,12 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), ], ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -230,6 +236,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { }, ), ], + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index a70ab123..4245f5b6 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -148,7 +148,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -207,7 +208,12 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ], ), // Expanded database children — ALL 16 databases - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), @@ -241,6 +247,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { onTap: () => widget.onDatabaseTap?.call(db.index), ), ], + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index f1994158..2e98ec37 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -245,7 +245,8 @@ class _FolderTileState extends State<_FolderTile> { children: [ material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 100), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 18, @@ -271,8 +272,9 @@ class _FolderTileState extends State<_FolderTile> { ), ), ), - if (_expanded) - material.Padding( + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Padding( padding: const material.EdgeInsets.only(left: 24), child: lazyConnectionTreeList( context: context, @@ -294,6 +296,7 @@ class _FolderTileState extends State<_FolderTile> { }, ), ), + ), ], ), ), diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 9cf93f7e..1397c669 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -2,6 +2,8 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Database type for new connection. @@ -383,8 +385,8 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { child: material.GestureDetector( onTap: widget.onTap, child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), padding: const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: material.BoxDecoration( color: highlighted ? t.muted.withValues(alpha: 0.4) : t.muted.withValues(alpha: 0.12), diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index b61720cd..1ec7e518 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,5 +1,8 @@ -import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -210,7 +213,7 @@ class _WorkspacePanelState extends State { ), const Divider(height: 1), Expanded( - child: IndexedStack( + child: QueryaCrossFadeStack( index: _editorTabIndex, children: const [ QueryEditorTab(), @@ -231,7 +234,7 @@ class _WorkspacePanelState extends State { ), const Divider(height: 1), Expanded( - child: IndexedStack( + child: QueryaCrossFadeStack( index: _outputTabIndex, children: const [ ResultsTab(), @@ -341,8 +344,8 @@ class _TabButtonState extends State<_TabButton> { child: material.GestureDetector( onTap: widget.onTap, child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( color: bgColor, @@ -376,8 +379,8 @@ class _RunButtonState extends State<_RunButton> { cursor: material.SystemMouseCursors.click, child: material.AnimatedScale( scale: _hovered ? 1.03 : 1.0, - duration: const Duration(milliseconds: 100), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), child: OutlineButton( onPressed: () {}, leading: const material.Icon(material.Icons.play_arrow, size: 18), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 580c93b6..2a474351 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -147,7 +147,7 @@ class _MysqlSqlWorkspaceState extends material.State { } final to = _statementTimeout(); - final rs = await conn.executeWithTimeout(userSql, timeout: to); + final rs = await conn.executeWithTimeout(userSql, timeout: to, iterable: true); if (!mounted) return; @@ -159,8 +159,12 @@ class _MysqlSqlWorkspaceState extends material.State { final rawRows = >[]; var n = 0; final cap = _resultMaxRows; - for (final row in rs.rows) { - if (n >= cap) break; + var truncated = false; + await for (final row in rs.rowsStream) { + if (n >= cap) { + truncated = true; + break; + } rawRows.add( List.generate(row.numOfColumns, (i) => row.colAt(i)), ); @@ -186,11 +190,9 @@ class _MysqlSqlWorkspaceState extends material.State { ? 'OK. Rows affected: $affected.' : 'Command completed.'; } else { - final total = rs.numOfRows; - final truncated = total > cap; _statusLine = truncated - ? 'Showing first $cap of $total row(s).' - : '$total row(s).'; + ? 'Showing first $cap row(s) (result capped).' + : '$n row(s).'; } _running = false; }); diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 94132a29..7a5860d4 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -1,6 +1,9 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; import 'package:querya_desktop/features/mysql/mysql_stats_view.dart'; @@ -82,7 +85,8 @@ class _MysqlWorkspaceHomeState extends material.State { child: material.GestureDetector( onTap: () => unawaited(_selectTab(i)), child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 8, @@ -106,9 +110,8 @@ class _MysqlWorkspaceHomeState extends material.State { ), const Divider(height: 1), Expanded( - child: material.IndexedStack( + child: QueryaCrossFadeStack( index: _tab, - sizing: material.StackFit.expand, children: [ MysqlStatsView( key: ValueKey('mysql_stats_${widget.connectionRow.id}'), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f4c5fd1d..c9158d67 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -261,7 +261,7 @@ class _PostgresSqlWorkspaceState extends material.State { Future _execute() async { final userSql = _sqlController.text.trim(); if (userSql.isEmpty) return; - var sql = userSql; + var sql = injectSqlLimit(userSql, _resultMaxRows); setState(() { _running = true; @@ -323,9 +323,9 @@ class _PostgresSqlWorkspaceState extends material.State { _statusLine = 'Command completed. Rows affected: ${result.affectedRows}.'; } else { - final truncated = result.length > cap; + final truncated = result.length >= cap; _statusLine = truncated - ? 'Showing first $cap of ${result.length} row(s).' + ? 'Showing first $cap row(s) (result capped).' : '${result.length} row(s).'; } _running = false; diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 7a170bc8..e80ee431 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -1,6 +1,9 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; @@ -123,7 +126,8 @@ class _PostgresWorkspaceHomeState extends material.State child: material.GestureDetector( onTap: () => _selectTab(i), child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 8, @@ -149,9 +153,8 @@ class _PostgresWorkspaceHomeState extends material.State ), const Divider(height: 1), Expanded( - child: material.IndexedStack( + child: QueryaCrossFadeStack( index: _tab, - sizing: material.StackFit.expand, children: [ PostgresStatsView( key: ValueKey('pg_stats_${widget.connectionRow.id}'), diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index afaedb4b..48e7d25e 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -76,17 +76,17 @@ class _RedisKeysViewState extends material.State { count: 100, ); - // Fetch type and TTL for each key - final infos = <_KeyInfo>[]; - for (final name in keyNames) { + // Fetch type and TTL for each key concurrently + final futures = keyNames.map((name) async { try { final type = await widget.connection.keyType(name); final ttl = await widget.connection.ttl(name); - infos.add(_KeyInfo(name: name, type: type, ttl: ttl)); + return _KeyInfo(name: name, type: type, ttl: ttl); } catch (_) { - infos.add(_KeyInfo(name: name, type: 'unknown', ttl: -1)); + return _KeyInfo(name: name, type: 'unknown', ttl: -1); } - } + }); + final infos = await Future.wait(futures); if (!mounted) return; setState(() { diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 0daff22d..d0a62a6a 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,6 +2,8 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_motion_controller.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/platform/open_directory.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; @@ -257,6 +259,40 @@ class _PreferencesAppearanceSectionState 'Smooth transitions when switching dark/light or presets. Off by default for stability.', ), const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Motion', + control: material.ListenableBuilder( + listenable: QueryaMotionController.instance, + builder: (context, _) { + final controller = QueryaMotionController.instance; + return PreferencesDropdownMenu( + value: controller.level, + onSelected: (v) { + if (v != null) unawaited(controller.setLevel(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: QueryaMotionLevel.full, + label: 'Full', + ), + material.DropdownMenuEntry( + value: QueryaMotionLevel.reduced, + label: 'Reduced', + ), + material.DropdownMenuEntry( + value: QueryaMotionLevel.off, + label: 'Off', + ), + ], + ); + }, + ), + ), + const material.SizedBox(height: 4), + const PreferencesHint( + 'Full enables all animations. Reduced cuts durations in half. Off disables all transitions.', + ), + const material.SizedBox(height: 12), material.Wrap( spacing: 8, runSpacing: 8, diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 1e108cd6..5dfac989 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; @@ -379,10 +381,8 @@ class _ThemePickerButtonState extends material.State { onEnter: _enabled ? (_) => setState(() => _triggerHovered = true) : null, onExit: _enabled ? (_) => setState(() => _triggerHovered = false) : null, child: material.AnimatedContainer( - duration: const Duration( - milliseconds: QueryaDropdownTokens.hoverAnimationMs, - ), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), height: triggerHeight, padding: QueryaDropdownTokens.scaledTriggerPadding(context), decoration: material.BoxDecoration( @@ -490,10 +490,8 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { onTap: widget.onSelected, borderRadius: material.BorderRadius.circular(radius), child: material.AnimatedContainer( - duration: const Duration( - milliseconds: QueryaDropdownTokens.hoverAnimationMs, - ), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), constraints: material.BoxConstraints(minHeight: itemHeight), padding: material.EdgeInsets.symmetric( horizontal: diff --git a/lib/main.dart b/lib/main.dart index 887d9a07..cbd63ec5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,15 +4,19 @@ import 'package:flutter/material.dart'; import 'app/app.dart'; import 'core/editor/syntax_highlight_service.dart'; import 'core/layout/ui_scale_controller.dart'; +import 'core/motion/display_refresh_service.dart'; +import 'core/motion/querya_motion_controller.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + DisplayRefreshService.initialize(); await LocalDb.initFfi(); await SyntaxHighlightService.ensureInitialized(); await ThemeController.instance.load(); await UiScaleController.instance.load(); + await QueryaMotionController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index a5e42ac5..36ff20e3 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -2,6 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; + /// Shows a modal dialog with a frosted, dimmed backdrop over the app. /// /// Use instead of [showDialog] so every overlay has consistent blur. @@ -15,7 +18,7 @@ Future showAppDialog({ barrierDismissible: false, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 200), + transitionDuration: context.motionDuration(QueryaMotion.standard), pageBuilder: (ctx, animation, secondaryAnimation) { return _BlurredDialogScaffold( barrierDismissible: barrierDismissible, @@ -44,12 +47,11 @@ class _BlurredDialogScaffold extends StatelessWidget { final Animation animation; final Widget child; - // Eased curve for dialog card fade-in / scale-up. - static final _curve = CurveTween(curve: Curves.easeOutCubic); - @override Widget build(BuildContext context) { - final curved = animation.drive(_curve); + final curved = animation.drive( + CurveTween(curve: context.motionCurve(QueryaMotion.enter)), + ); return Material( type: MaterialType.transparency, child: Stack( diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index cfcd5860..1e6eb8c1 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -57,6 +59,7 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; bool _triggerHovered = false; + bool _menuOpen = false; List? _cachedMenuChildren; List>? _cachedMenuItems; T? _cachedMenuValue; @@ -154,10 +157,8 @@ class _QueryaDropdownState extends material.State> { onEnter: widget.enabled ? (_) => setState(() => _triggerHovered = true) : null, onExit: widget.enabled ? (_) => setState(() => _triggerHovered = false) : null, child: material.AnimatedContainer( - duration: const Duration( - milliseconds: QueryaDropdownTokens.hoverAnimationMs, - ), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), height: triggerHeight, padding: QueryaDropdownTokens.scaledTriggerPadding(context), decoration: material.BoxDecoration( @@ -229,6 +230,8 @@ class _QueryaDropdownState extends material.State> { final anchor = material.MenuAnchor( controller: _controller, + onOpen: () => setState(() => _menuOpen = true), + onClose: () => setState(() => _menuOpen = false), crossAxisUnconstrained: false, alignmentOffset: material.Offset( widget.alignmentOffset.dx, @@ -257,7 +260,16 @@ class _QueryaDropdownState extends material.State> { ), ), ), - menuChildren: menuChildren, + menuChildren: [ + _QueryaDropdownMenuEnter( + open: _menuOpen, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: menuChildren, + ), + ), + ], builder: (context, controller, child) { final trigger = _buildTrigger( context: context, @@ -280,6 +292,34 @@ class _QueryaDropdownState extends material.State> { } } +class _QueryaDropdownMenuEnter extends material.StatelessWidget { + const _QueryaDropdownMenuEnter({ + required this.open, + required this.child, + }); + + final bool open; + final material.Widget child; + + @override + material.Widget build(material.BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); + return material.AnimatedScale( + scale: open ? 1 : 0.96, + alignment: material.Alignment.topCenter, + duration: duration, + curve: curve, + child: material.AnimatedOpacity( + opacity: open ? 1 : 0, + duration: duration, + curve: curve, + child: child, + ), + ); + } +} + class _QueryaDropdownMenuItem extends material.StatefulWidget { const _QueryaDropdownMenuItem({ required this.item, @@ -350,10 +390,8 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu ), onPressed: widget.enabled ? widget.onPick : null, child: material.AnimatedContainer( - duration: const Duration( - milliseconds: QueryaDropdownTokens.hoverAnimationMs, - ), - curve: material.Curves.easeOut, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), constraints: material.BoxConstraints(minHeight: itemHeight), padding: material.EdgeInsets.symmetric( horizontal: context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart index 367bbaaf..6a4a3721 100644 --- a/lib/shared/widgets/querya_dropdown_tokens.dart +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -40,8 +40,6 @@ abstract final class QueryaDropdownTokens { static const double selectedCheckSlotWidth = 18.0; - static const int hoverAnimationMs = 120; - static double scaledTriggerHeight(material.BuildContext context) => context.scaled(triggerHeight); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 49d2eb91..f09d0a3b 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -25,6 +26,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); + g_autoptr(FlPluginRegistrar) refresh_rate_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RefreshRatePlugin"); + refresh_rate_plugin_register_with_registrar(refresh_rate_registrar); g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 0516a6e3..7792787e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux irondash_engine_context + refresh_rate super_native_extensions ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5644fad5..d30bbdad 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import device_info_plus import file_selector_macos import flutter_secure_storage_macos import irondash_engine_context +import refresh_rate import sqflite_darwin import super_native_extensions @@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + RefreshRatePlugin.register(with: registry.registrar(forPlugin: "RefreshRatePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index e764db20..008edd8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: sdk: flutter http: ^1.2.2 crypto: ^3.0.6 + refresh_rate: ^1.0.2 shadcn_flutter: ^0.0.52 bitsdojo_window: ^0.1.6 path: ^1.9.0 @@ -38,6 +39,9 @@ dependency_overrides: # Patched ToastLayer (fixes InheritedNotifier crash on resize / hot reload). shadcn_flutter: path: third_party/shadcn_flutter + # Linux -Werror: fix uninitialized rates[0] in refresh_rate 1.0.2. + refresh_rate: + path: third_party/refresh_rate flutter: uses-material-design: true diff --git a/test/core/database/postgres_sql_test.dart b/test/core/database/postgres_sql_test.dart index 6b7c70cc..0d49cac0 100644 --- a/test/core/database/postgres_sql_test.dart +++ b/test/core/database/postgres_sql_test.dart @@ -79,4 +79,33 @@ void main() { expect(shouldSkipImplicitBegin('UPDATE t SET x = 1'), isFalse); }); }); + + group('injectSqlLimit', () { + test('appends LIMIT to select query without limit', () { + expect(injectSqlLimit('SELECT * FROM users', 5000), 'SELECT * FROM users\nLIMIT 5000'); + }); + + test('handles trailing semicolons', () { + expect(injectSqlLimit('SELECT * FROM users;', 5000), 'SELECT * FROM users\nLIMIT 5000;'); + expect(injectSqlLimit('SELECT * FROM users; ', 5000), 'SELECT * FROM users\nLIMIT 5000;'); + expect(injectSqlLimit('SELECT * FROM users;;', 5000), 'SELECT * FROM users\nLIMIT 5000;;'); + }); + + test('does not append LIMIT if LIMIT already exists', () { + expect(injectSqlLimit('SELECT * FROM users LIMIT 10', 5000), 'SELECT * FROM users LIMIT 10'); + expect(injectSqlLimit('SELECT * FROM users limit 10;', 5000), 'SELECT * FROM users limit 10;'); + }); + + test('does not modify non-select/non-read queries', () { + expect(injectSqlLimit('INSERT INTO users VALUES (1)', 5000), 'INSERT INTO users VALUES (1)'); + expect(injectSqlLimit('UPDATE users SET x = 1', 5000), 'UPDATE users SET x = 1'); + }); + + test('appends LIMIT to WITH query', () { + expect( + injectSqlLimit('WITH t AS (SELECT * FROM users) SELECT * FROM t;', 5000), + 'WITH t AS (SELECT * FROM users) SELECT * FROM t\nLIMIT 5000;', + ); + }); + }); } diff --git a/test/core/motion/display_refresh_service_test.dart b/test/core/motion/display_refresh_service_test.dart new file mode 100644 index 00000000..f0f97422 --- /dev/null +++ b/test/core/motion/display_refresh_service_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/display_refresh_service.dart'; + +void main() { + group('displayRefreshOverlayEnabled', () { + test('false in release or when flag unset', () { + expect( + DisplayRefreshService.displayRefreshOverlayEnabled( + debugMode: false, + overlayFlag: true, + ), + isFalse, + ); + expect( + DisplayRefreshService.displayRefreshOverlayEnabled( + debugMode: true, + overlayFlag: false, + ), + isFalse, + ); + }); + + test('true only in debug with QUERYA_REFRESH_OVERLAY', () { + expect( + DisplayRefreshService.displayRefreshOverlayEnabled( + debugMode: true, + overlayFlag: true, + ), + isTrue, + ); + }); + }); +} diff --git a/test/core/motion/querya_animated_expand_test.dart b/test/core/motion/querya_animated_expand_test.dart new file mode 100644 index 00000000..c92a4045 --- /dev/null +++ b/test/core/motion/querya_animated_expand_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; + +void main() { + testWidgets('QueryaAnimatedExpand hides child when collapsed', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: false), + ), + ); + + expect(find.text('child'), findsNothing); + }); + + testWidgets('QueryaAnimatedExpand shows child when expanded', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: true), + ), + ); + + expect(find.text('child'), findsOneWidget); + }); +} + +class _ExpandHost extends StatelessWidget { + const _ExpandHost({required this.expanded}); + + final bool expanded; + + @override + Widget build(BuildContext context) { + return QueryaAnimatedExpand( + expanded: expanded, + child: const Text('child'), + ); + } +} diff --git a/test/core/motion/querya_cross_fade_stack_test.dart b/test/core/motion/querya_cross_fade_stack_test.dart new file mode 100644 index 00000000..0a829ad6 --- /dev/null +++ b/test/core/motion/querya_cross_fade_stack_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; + +void main() { + testWidgets('QueryaCrossFadeStack cross-fades children and excludes focus', (WidgetTester tester) async { + final focusNode1 = FocusNode(); + final focusNode2 = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QueryaCrossFadeStack( + index: 0, + children: [ + TextField( + key: const Key('input_active'), + focusNode: focusNode1, + ), + TextField( + key: const Key('input_hidden'), + focusNode: focusNode2, + ), + ], + ), + ), + ), + ); + + // 1. Verify index 0 is visible (opacity 1.0) and index 1 is invisible (opacity 0.0) + final animatedOpacityFinder = find.byType(AnimatedOpacity); + expect(animatedOpacityFinder, findsNWidgets(2)); + + final opacity1 = tester.widget(animatedOpacityFinder.at(0)).opacity; + final opacity2 = tester.widget(animatedOpacityFinder.at(1)).opacity; + expect(opacity1, 1.0); + expect(opacity2, 0.0); + + // 2. Focus the active TextField + focusNode1.requestFocus(); + await tester.pump(); + expect(focusNode1.hasFocus, isTrue); + + // 3. Attempt to focus the hidden TextField + focusNode2.requestFocus(); + await tester.pump(); + // It should NOT have focus because it is wrapped in ExcludeFocus(excluding: true) + expect(focusNode2.hasFocus, isFalse); + + // Cleanup + focusNode1.dispose(); + focusNode2.dispose(); + }); +} diff --git a/test/core/motion/querya_motion_controller_test.dart b/test/core/motion/querya_motion_controller_test.dart new file mode 100644 index 00000000..c42ddbae --- /dev/null +++ b/test/core/motion/querya_motion_controller_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/motion/querya_motion_controller.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_motion_controller_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('QueryaMotionController default/off/reduced logic', () async { + final controller = QueryaMotionController.instance; + + // Initially loads default (full) + await controller.load(); + expect(controller.level, QueryaMotionLevel.full); + + var notifyCount = 0; + void listener() => notifyCount++; + controller.addListener(listener); + + try { + // Sets and persists reduced + await controller.setLevel(QueryaMotionLevel.reduced); + expect(controller.level, QueryaMotionLevel.reduced); + expect(notifyCount, 1); + + // Sets and persists off + await controller.setLevel(QueryaMotionLevel.off); + expect(controller.level, QueryaMotionLevel.off); + expect(notifyCount, 2); + + // Same level does not notify + await controller.setLevel(QueryaMotionLevel.off); + expect(notifyCount, 2); + + // Load from DB restores level + final anotherController = QueryaMotionController.instance; + await anotherController.load(); + expect(anotherController.level, QueryaMotionLevel.off); + } finally { + controller.removeListener(listener); + } + }); +} diff --git a/test/core/motion/querya_motion_test.dart b/test/core/motion/querya_motion_test.dart new file mode 100644 index 00000000..a7d91d0c --- /dev/null +++ b/test/core/motion/querya_motion_test.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; + +void main() { + group('QueryaMotion tokens', () { + test('duration constants match design doc', () { + expect(QueryaMotion.instant, Duration.zero); + expect(QueryaMotion.fast, const Duration(milliseconds: 120)); + expect(QueryaMotion.standard, const Duration(milliseconds: 200)); + expect(QueryaMotion.slow, const Duration(milliseconds: 320)); + }); + + test('curve constants are set', () { + expect(QueryaMotion.enter, Curves.easeOutCubic); + expect(QueryaMotion.exit, Curves.easeInCubic); + expect(QueryaMotion.standardCurve, Curves.easeInOutCubic); + expect(QueryaMotion.emphasized, Curves.easeInOutCubicEmphasized); + }); + }); + + group('effectiveDuration', () { + testWidgets('returns token when motion is full', (tester) async { + late Duration result; + + await tester.pumpWidget( + _MotionProbe( + disableAnimations: false, + level: QueryaMotionLevel.full, + onDuration: (d) => result = d, + ), + ); + + expect(result, QueryaMotion.standard); + }); + + testWidgets('returns instant when OS disableAnimations is true', ( + tester, + ) async { + late Duration result; + + await tester.pumpWidget( + _MotionProbe( + disableAnimations: true, + level: QueryaMotionLevel.full, + onDuration: (d) => result = d, + ), + ); + + expect(result, QueryaMotion.instant); + }); + + testWidgets('returns instant when motion level is off', (tester) async { + late Duration result; + + await tester.pumpWidget( + _MotionProbe( + disableAnimations: false, + level: QueryaMotionLevel.off, + onDuration: (d) => result = d, + ), + ); + + expect(result, QueryaMotion.instant); + }); + + testWidgets('halves duration when motion level is reduced', (tester) async { + late Duration result; + + await tester.pumpWidget( + _MotionProbe( + disableAnimations: false, + level: QueryaMotionLevel.reduced, + onDuration: (d) => result = d, + ), + ); + + expect(result, const Duration(milliseconds: 100)); + }); + + testWidgets('defaults to full when QueryaMotionScope is absent', ( + tester, + ) async { + late Duration result; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + result = context.motionDuration(QueryaMotion.fast); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(result, QueryaMotion.fast); + }); + }); + + group('effectiveCurve', () { + testWidgets('returns linear when animations disabled', (tester) async { + late Curve result; + + await tester.pumpWidget( + _MotionCurveProbe( + disableAnimations: true, + level: QueryaMotionLevel.full, + onCurve: (c) => result = c, + ), + ); + + expect(result, Curves.linear); + }); + + testWidgets('returns token curve when motion is full', (tester) async { + late Curve result; + + await tester.pumpWidget( + _MotionCurveProbe( + disableAnimations: false, + level: QueryaMotionLevel.full, + onCurve: (c) => result = c, + ), + ); + + expect(result, QueryaMotion.enter); + }); + }); +} + +class _MotionProbe extends StatelessWidget { + const _MotionProbe({ + required this.disableAnimations, + required this.level, + required this.onDuration, + }); + + final bool disableAnimations; + final QueryaMotionLevel level; + final ValueChanged onDuration; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: QueryaMotionScope( + level: level, + child: Builder( + builder: (context) { + onDuration( + QueryaMotion.effectiveDuration(context, QueryaMotion.standard), + ); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + } +} + +class _MotionCurveProbe extends StatelessWidget { + const _MotionCurveProbe({ + required this.disableAnimations, + required this.level, + required this.onCurve, + }); + + final bool disableAnimations; + final QueryaMotionLevel level; + final ValueChanged onCurve; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: QueryaMotionScope( + level: level, + child: Builder( + builder: (context) { + onCurve(QueryaMotion.effectiveCurve(context, QueryaMotion.enter)); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + } +} diff --git a/test/core/theme/theme_remote_install_policy_test.dart b/test/core/theme/theme_remote_install_policy_test.dart index 3114256b..b908cc00 100644 --- a/test/core/theme/theme_remote_install_policy_test.dart +++ b/test/core/theme/theme_remote_install_policy_test.dart @@ -57,5 +57,53 @@ void main() { isFalse, ); }); + + test('rejects link-local, unique-local, unspecified, and multicast IPv6 addresses', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[fe80::1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[fd00::1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[ff02::1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[::]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('rejects loopback and private IPv4-mapped IPv6 addresses', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[::ffff:127.0.0.1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[::ffff:192.168.1.10]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); }); } diff --git a/third_party/refresh_rate/CHANGELOG.md b/third_party/refresh_rate/CHANGELOG.md new file mode 100644 index 00000000..6f9b880f --- /dev/null +++ b/third_party/refresh_rate/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## 1.0.2 + +- Added Swift Package Manager support. + +## 1.0.1 + +### Web support +- Added web platform implementation using `requestAnimationFrame` interval + timing to detect the display's current refresh rate (same technique as TestUFO). +- Control methods are graceful no-ops (browsers own vsync scheduling). +- FPS overlay and benchmark sessions work unchanged on web. + +### Documentation +- Added comprehensive Dartdoc comments to all public APIs. + +### Platform fixes +- Restructured `Package.swift` into `ios/refresh_rate/` and `macos/refresh_rate/` + for correct Swift Package Manager module resolution. +- Resolved minor lint warnings from test suites. + +## 1.0.0 + +Initial stable release — unlock, query, overlay, and benchmark display refresh rates across all Flutter platforms. + +### Control + +- `RefreshRate.enable()` — one-line unlock of peak display refresh rate +- `RefreshRate.preferMax()` / `preferDefault()` — explicit rate preference +- `RefreshRate.matchContent(fps)` — sync display cadence to content frame rate (fixes 24 fps video judder) +- `RefreshRate.boost(duration)` — temporary max-rate spike for gesture-driven animations +- `RefreshRate.category(RateCategory)` — Android 15 semantic rate category +- `RefreshRate.setTouchBoost(bool)` — Android 15 touch-driven rate boost + +### Diagnostics + +- `RefreshRate.info` — synchronous cached `RefreshRateInfo` snapshot (current rate, max, min, supported rates, VRR, API level) +- `RefreshRate.onChanged` — stream fires on rate change, Low Power Mode toggle, or thermal state change +- `RefreshRate.isLowPowerMode` / `thermalState` / `isProMotionReady` + +### Debug overlay + +- `RefreshRate.showOverlay()` — full diagnostic HUD with live FPS, build/raster timings, frame budget +- `RefreshRate.showFPS()` / `showHz()` — individual overlay badges +- FPS colour is relative to the device's actual target rate, not a hard-coded 60 Hz baseline + +### Benchmark sessions + +- `RefreshRate.startSession(name)` returns `RefreshRateSession` +- `session.end()` returns `SessionReport` with verdict, bottleneck, avgFps, 1% low FPS, missed-frame %, and JSON export +- Sessions auto-exclude backgrounded periods, resume warmup, LPM changes, and thermal changes + +### Platform coverage + +- **Android 6+** (API 23): `SurfaceControl.Transaction.setFrameRate()` (API 34+), `preferredRefreshRate` + `preferredDisplayModeId` (API 30–33), legacy `preferredDisplayModeId` fallback (API 23–29) +- **iOS 15+**: `CADisplayLink.preferredFrameRateRange` with runtime `Info.plist` ProMotion validation +- **macOS 14+**: `CADisplayLink`-based control via `NSView.displayLink` +- **macOS < 14 / Windows / Linux**: query-only (reports real monitor refresh rate) +- All platform bridges use [pigeon](https://pub.dev/packages/pigeon) — fully typed, zero codec overhead diff --git a/third_party/refresh_rate/LICENSE b/third_party/refresh_rate/LICENSE new file mode 100644 index 00000000..5bdae8a2 --- /dev/null +++ b/third_party/refresh_rate/LICENSE @@ -0,0 +1,24 @@ +Copyright 2026 Qoder. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Qoder nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/refresh_rate/README.md b/third_party/refresh_rate/README.md new file mode 100644 index 00000000..1d07f6a9 --- /dev/null +++ b/third_party/refresh_rate/README.md @@ -0,0 +1,230 @@ +# refresh_rate + +[![pub package](https://img.shields.io/pub/v/refresh_rate.svg)](https://pub.dev/packages/refresh_rate) +[![pub points](https://img.shields.io/pub/points/refresh_rate)](https://pub.dev/packages/refresh_rate/score) +[![likes](https://img.shields.io/pub/likes/refresh_rate)](https://pub.dev/packages/refresh_rate/score) +[![popularity](https://img.shields.io/pub/popularity/refresh_rate)](https://pub.dev/packages/refresh_rate/score) +[![License: BSD-3](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://github.com/qoder-official/refresh_rate/blob/main/LICENSE) + +**Unlock your device's full refresh rate in one line of Flutter.** + +Your Flutter app runs at 60 Hz on a 120 Hz phone right now. The engine never tells the OS compositor it can handle more. `refresh_rate` fixes that — and gives you diagnostics, benchmarks, and a live overlay to prove it. + +```dart +void main() { + RefreshRate.enable(); // that's it — 120 Hz on a 120 Hz device + runApp(const MyApp()); +} +``` + +> **Why does this happen?** Flutter's engine never calls Android's `Surface.setFrameRate()` and the default iOS template is missing the `CADisableMinimumFrameDurationOnPhone` plist key. See [Flutter #160952](https://github.com/flutter/flutter/issues/160952). This package makes those calls for you. + +Built on [pigeon](https://pub.dev/packages/pigeon) — fully typed end-to-end, zero `MethodChannel` codec overhead. + +--- + +## Platform support + +| Platform | Unlock | Query | Overlay | Benchmark | +|:---------|:------:|:-----:|:-------:|:---------:| +| **Android** 6+ (API 23) | ✅ | ✅ | ✅ | ✅ | +| **iOS** 15+ (ProMotion) | ✅ \* | ✅ | ✅ | ✅ | +| **macOS** 14+ (Sonoma) | ✅ | ✅ | ✅ | ✅ | +| **macOS** < 14 | — | ✅ | ✅ | ✅ | +| **Windows** | — | ✅ | ✅ | ✅ | +| **Linux** | — | ✅ | ✅ | ✅ | + +\* iOS requires `Info.plist` key — see [iOS setup](#ios-setup). + +--- + +## Installation + +```yaml +dependencies: + refresh_rate: ^1.0.0 +``` + +### iOS setup + +Add to `ios/Runner/Info.plist` — required for > 60 Hz on iPhones with ProMotion: + +```xml +CADisableMinimumFrameDurationOnPhone + +``` + +Without this key, iOS caps your app at 60 Hz even on 120 Hz hardware. The plugin detects this at runtime and prints a console warning if missing. iPad Pro does **not** need this key. + +--- + +## Quick start + +```dart +import 'package:refresh_rate/refresh_rate.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + RefreshRate.enable(); // unlocks peak rate on every supported device + runApp(const MyApp()); +} +``` + +One call in `main()`. On a 120 Hz device your app now renders at 120 Hz. On a 60 Hz device nothing changes — the OS stays in control. + +--- + +## Diagnostics + +```dart +final info = RefreshRate.info; // synchronous cached snapshot + +print(info.currentRate); // 120.0 +print(info.maxRate); // 120.0 +print(info.supportedRates); // [60.0, 90.0, 120.0] +print(info.isVariableRefreshRate); // true (LTPO panel) +print(info.androidApiLevel); // 34 (Android only) +print(info.displayServer); // "wayland" (Linux only) + +print(RefreshRate.isLowPowerMode); // false +print(RefreshRate.thermalState); // ThermalState.nominal +print(RefreshRate.isProMotionReady); // true — plist key + hardware both present + +await RefreshRate.refresh(); // reload platform cache + +RefreshRate.onChanged.listen((info) { // rate, Low Power Mode, or thermal change + print('Now running at ${info.currentRate} Hz'); +}); +``` + +--- + +## Advanced control + +```dart +RefreshRate.preferMax(); // highest available rate +RefreshRate.preferDefault(); // return to OS default +RefreshRate.matchContent(24.0); // sync to 24 fps video (fixes judder) +RefreshRate.boost(const Duration(seconds: 2)); // temporary spike for gestures + +// Android 15+ +RefreshRate.category(RateCategory.high); // semantic rate category +RefreshRate.setTouchBoost(true); // OS-managed touch boost +``` + +--- + +## Debug overlay + +Drop a live performance HUD into any debug build: + +```dart +if (kDebugMode) RefreshRate.showOverlay(); +``` + +The overlay is **refresh-rate-aware**: FPS is colour-coded against the device's *actual* target Hz, not a hard-coded 60 Hz baseline. It also shows per-frame build/raster timings, frame budget, Low Power Mode, and thermal state. + +```dart +RefreshRate.showFPS(); // just the FPS counter +RefreshRate.showHz(); // just the Hz badge +RefreshRate.showOverlay(); // full diagnostic panel +RefreshRate.hideOverlay(); // dismiss +``` + +--- + +## Benchmark sessions + +Record a named performance window and get a structured report: + +```dart +final session = RefreshRate.startSession('home_scroll'); + +// ... user interacts ... + +final report = await session.end(); + +print(report.verdict); // Verdict.good / degraded / poor +print(report.likelyBottleneck); // Bottleneck.rasterBound / buildBound / none +print(report.avgFps); // 108.4 +print(report.onePercentLowFps); // 87.2 +print(report.missedFramePercent); // 3.2% + +final json = report.toJson(); // export for CI / QA dashboards +``` + +Sessions automatically exclude app backgrounding, resume warmup, Low Power Mode toggles, and thermal state changes — so numbers reflect real rendering performance. + +--- + +## How it works + +### Android + +| API Level | What the plugin calls | +|:---------:|:----------------------| +| **34+** | `SurfaceControl.Transaction.setFrameRate()` — direct SurfaceFlinger vote | +| **30–33** | `preferredRefreshRate` + `preferredDisplayModeId` — dual hint | +| **23–29** | `preferredDisplayModeId` with resolution-match guard — legacy fallback | + +Flutter never calls `Surface.setFrameRate()`. That single missing call is why 120 Hz phones render Flutter at 60 Hz. + +### iOS + +Sets `CADisplayLink.preferredFrameRateRange` with the device max. Validates the `CADisableMinimumFrameDurationOnPhone` plist key at runtime and warns loudly if missing. + +### macOS + +`NSView.displayLink` with `preferredFrameRateRange` on macOS 14+. Falls back to `NSScreen.maximumFramesPerSecond` / `CGDisplayCopyDisplayMode` for query. + +### Windows & Linux + +Query-only via `QueryDisplayConfig` (Win) and `gdk_monitor_get_refresh_rate` (Linux). Control depends on Flutter's desktop embedder evolution — tracked at [#93058](https://github.com/flutter/flutter/issues/93058) and [#183703](https://github.com/flutter/flutter/issues/183703). + +--- + +## API reference + +| Method | What it does | +|:-------|:-------------| +| `enable()` | Unlock peak rate — call once in `main()` | +| `disable()` | Stop overriding, return to OS default | +| `preferMax()` | Request highest available rate | +| `preferDefault()` | Clear rate override | +| `matchContent(fps)` | Sync display cadence to content frame rate | +| `boost(duration)` | Temporary max-rate spike | +| `category(cat)` | Android 15 semantic rate category | +| `setTouchBoost(bool)` | Android 15 touch-driven boost | +| `refresh()` | Reload platform info cache | +| `info` | Cached `RefreshRateInfo` snapshot | +| `isLowPowerMode` | Battery Saver / Low Power Mode active | +| `thermalState` | `ThermalState` enum | +| `isProMotionReady` | iOS plist key + ProMotion hardware | +| `onChanged` | `Stream` | +| `showFPS()` | Live FPS counter overlay | +| `showHz()` | Live Hz badge overlay | +| `showOverlay()` | Full diagnostic overlay | +| `hideOverlay()` | Dismiss overlay | +| `startSession(name)` | Start benchmark session | + +--- + +## Why this exists + +Flutter's engine (Impeller since 3.24) *can* render at 120 Hz. But it never *tells the OS*. On Android, `Surface.setFrameRate()` returns zero search results across the entire engine codebase. On iOS, the engine code is correct but the default template omits the plist key. + +This has been open since January 2023 ([#119268](https://github.com/flutter/flutter/issues/119268)), currently tracked at [#160952](https://github.com/flutter/flutter/issues/160952) (P2, unassigned). + +`refresh_rate` fixes this today on shipping apps, while collecting real-device evidence for an eventual engine-level fix. + +--- + +## License + +BSD 3-Clause © 2026 [Qoder](https://qoder.in) + +--- + +

+ Made with care by Qoder · More packages +

diff --git a/third_party/refresh_rate/analysis_options.yaml b/third_party/refresh_rate/analysis_options.yaml new file mode 100644 index 00000000..b0f3c0af --- /dev/null +++ b/third_party/refresh_rate/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml +linter: + rules: + - public_member_api_docs diff --git a/third_party/refresh_rate/android/build.gradle b/third_party/refresh_rate/android/build.gradle new file mode 100644 index 00000000..484f253e --- /dev/null +++ b/third_party/refresh_rate/android/build.gradle @@ -0,0 +1,51 @@ +group = "in.qoder.refresh_rate" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "1.9.22" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.1.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "in.qoder.refresh_rate" + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } + + defaultConfig { + minSdk = 21 + } +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") +} diff --git a/third_party/refresh_rate/android/settings.gradle b/third_party/refresh_rate/android/settings.gradle new file mode 100644 index 00000000..f27361d7 --- /dev/null +++ b/third_party/refresh_rate/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "refresh_rate" diff --git a/third_party/refresh_rate/android/src/main/AndroidManifest.xml b/third_party/refresh_rate/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1e375ae6 --- /dev/null +++ b/third_party/refresh_rate/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt b/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt new file mode 100644 index 00000000..596430dd --- /dev/null +++ b/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt @@ -0,0 +1,248 @@ +package `in`.qoder.refresh_rate + +import android.app.Activity +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.PowerManager +import android.view.Display +import android.view.Surface +import android.view.View +import android.view.WindowManager +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import `in`.qoder.refresh_rate.generated.DisplayInfoMessage +import `in`.qoder.refresh_rate.generated.RefreshRateFlutterApi +import `in`.qoder.refresh_rate.generated.RefreshRateHostApi + +class RefreshRatePlugin : FlutterPlugin, ActivityAware, RefreshRateHostApi { + + private var activity: Activity? = null + private var context: Context? = null + private var flutterApi: RefreshRateFlutterApi? = null + private var displayListener: DisplayManager.DisplayListener? = null + + // ─── FlutterPlugin ────────────────────────────────────────── + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + RefreshRateHostApi.setUp(binding.binaryMessenger, this) + flutterApi = RefreshRateFlutterApi(binding.binaryMessenger) + registerDisplayListener() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + RefreshRateHostApi.setUp(binding.binaryMessenger, null) + unregisterDisplayListener() + flutterApi = null + } + + // ─── ActivityAware ────────────────────────────────────────── + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity } + override fun onDetachedFromActivityForConfigChanges() { activity = null } + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity } + override fun onDetachedFromActivity() { activity = null } + + // ─── RefreshRateHostApi ───────────────────────────────────── + + override fun getDisplayInfo(): DisplayInfoMessage { + val display = getDisplay() + val currentRate = display?.refreshRate?.toDouble() ?: 60.0 + val modes = display?.supportedModes ?: emptyArray() + val supportedRates = modes.map { it.refreshRate.toDouble() }.distinct().sorted() + val maxRate = supportedRates.maxOrNull() ?: 60.0 + val minRate = supportedRates.minOrNull() ?: 60.0 + val isVRR = (maxRate - minRate > 30) && modes.size <= 4 + val pm = context?.getSystemService(Context.POWER_SERVICE) as? PowerManager + val thermalIndex: Long? = if (Build.VERSION.SDK_INT >= 29) { + when (pm?.currentThermalStatus) { + PowerManager.THERMAL_STATUS_NONE -> 0L + PowerManager.THERMAL_STATUS_LIGHT, PowerManager.THERMAL_STATUS_MODERATE -> 1L + PowerManager.THERMAL_STATUS_SEVERE -> 2L + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN -> 3L + else -> null + } + } else null + + // hasArrSupport() is API 36+ — fall back to VRR heuristic for now + val hasArr = isVRR + + return DisplayInfoMessage( + currentRate = currentRate, + maxRate = maxRate, + minRate = minRate, + supportedRates = supportedRates, + isVariableRefreshRate = isVRR, + engineTargetRate = currentRate, + androidApiLevel = Build.VERSION.SDK_INT.toLong(), + isLowPowerMode = pm?.isPowerSaveMode, + thermalStateIndex = thermalIndex, + hasAdaptiveRefreshRate = hasArr, + iosProMotionEnabled = null, + displayServer = null, + monitorCount = null, + ) + } + + override fun enable() = setDeviceDefault() + override fun disable() = resetToDefault() + override fun preferMax() = setDeviceDefault() + override fun preferDefault() = resetToDefault() + + override fun matchContent(fps: Double) { + if (Build.VERSION.SDK_INT >= 30) { + setSurfaceFrameRate(fps.toFloat(), Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, forceAlways = true) + } else if (Build.VERSION.SDK_INT >= 23) { + setPreferredDisplayMode(fps.toFloat()) + } + } + + override fun boost(durationMs: Long) { + val display = getDisplay() ?: return + val maxRate = display.supportedModes.maxByOrNull { it.refreshRate }?.refreshRate ?: 60f + if (Build.VERSION.SDK_INT >= 35) { + try { activity?.window?.decorView?.setRequestedFrameRate(View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat()) } catch (_: Exception) {} + } + setSurfaceFrameRate(maxRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT, forceAlways = true) + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ resetToDefault() }, durationMs) + } + + override fun setCategory(categoryIndex: Long) { + if (Build.VERSION.SDK_INT < 35) { + when (categoryIndex.toInt()) { + 3 -> setDeviceDefault() + 0, 1 -> resetToDefault() + else -> {} + } + return + } + try { + val categoryFloat = when (categoryIndex.toInt()) { + 0 -> 0f + 1 -> View.REQUESTED_FRAME_RATE_CATEGORY_LOW.toFloat() + 2 -> View.REQUESTED_FRAME_RATE_CATEGORY_NORMAL.toFloat() + 3 -> View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat() + else -> View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat() + } + activity?.window?.decorView?.setRequestedFrameRate(categoryFloat) + } catch (_: Exception) {} + } + + override fun setTouchBoost(enabled: Boolean) { + if (Build.VERSION.SDK_INT >= 35) { + try { activity?.window?.setFrameRateBoostOnTouchEnabled(enabled) } catch (_: Exception) {} + } + } + + override fun isSupported(): Boolean = Build.VERSION.SDK_INT >= 23 + + // ─── Private helpers ──────────────────────────────────────── + + private fun setDeviceDefault() { + val display = getDisplay() ?: return + val maxRate = display.supportedModes.maxByOrNull { it.refreshRate }?.refreshRate ?: 60f + if (Build.VERSION.SDK_INT >= 35) { + try { + val window = activity?.window + window?.decorView?.setRequestedFrameRate(View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat()) + window?.setFrameRateBoostOnTouchEnabled(true) + } catch (_: Exception) {} + setSurfaceFrameRate(maxRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) + } else if (Build.VERSION.SDK_INT >= 30) { + setSurfaceFrameRate(maxRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) + } else if (Build.VERSION.SDK_INT >= 23) { + setPreferredDisplayMode(maxRate) + } + } + + private fun resetToDefault() { + if (Build.VERSION.SDK_INT >= 35) { + try { + val window = activity?.window + window?.decorView?.setRequestedFrameRate(0f) + window?.setFrameRateBoostOnTouchEnabled(false) + } catch (_: Exception) {} + setSurfaceFrameRate(0f, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) + } else if (Build.VERSION.SDK_INT >= 30) { + setSurfaceFrameRate(0f, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) + } else if (Build.VERSION.SDK_INT >= 23) { + try { + val params = activity?.window?.attributes ?: return + params.preferredDisplayModeId = 0 + activity?.window?.attributes = params + } catch (_: Exception) {} + } + } + + private fun setSurfaceFrameRate(frameRate: Float, compatibility: Int, forceAlways: Boolean = false): Boolean { + if (Build.VERSION.SDK_INT < 30) return false + return try { + val window = activity?.window ?: return false + val strategy = if (Build.VERSION.SDK_INT >= 31) { + if (forceAlways) Surface.CHANGE_FRAME_RATE_ALWAYS + else Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + } else -1 + val params = window.attributes + params.preferredRefreshRate = frameRate + val display = getDisplay() + if (display != null && frameRate > 0) { + val targetMode = display.supportedModes + .filter { it.refreshRate >= frameRate - 1f } + .minByOrNull { Math.abs(it.refreshRate - frameRate) } + if (targetMode != null) params.preferredDisplayModeId = targetMode.modeId + } else if (frameRate == 0f) { + params.preferredDisplayModeId = 0 + params.preferredRefreshRate = 0f + } + window.attributes = params + true + } catch (e: Exception) { false } + } + + private fun setPreferredDisplayMode(targetRate: Float): Boolean { + if (Build.VERSION.SDK_INT < 23) return false + return try { + val window = activity?.window ?: return false + val display = getDisplay() ?: return false + val currentMode = display.mode + val targetMode = display.supportedModes + .filter { it.physicalWidth == currentMode.physicalWidth && it.physicalHeight == currentMode.physicalHeight } + .minByOrNull { Math.abs(it.refreshRate - targetRate) } + if (targetMode != null) { + val params = window.attributes + params.preferredDisplayModeId = targetMode.modeId + window.attributes = params + true + } else false + } catch (e: Exception) { false } + } + + private fun registerDisplayListener() { + val dm = context?.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return + displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayChanged(displayId: Int) { + activity?.runOnUiThread { flutterApi?.onDisplayInfoChanged(getDisplayInfo()) {} } + } + override fun onDisplayAdded(displayId: Int) {} + override fun onDisplayRemoved(displayId: Int) {} + } + dm.registerDisplayListener(displayListener, null) + } + + private fun unregisterDisplayListener() { + val dm = context?.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return + displayListener?.let { dm.unregisterDisplayListener(it) } + displayListener = null + } + + @Suppress("DEPRECATION") + private fun getDisplay(): Display? = if (Build.VERSION.SDK_INT >= 30) { + activity?.display + } else { + (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.defaultDisplay + } +} diff --git a/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt b/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt new file mode 100644 index 00000000..b72597dc --- /dev/null +++ b/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt @@ -0,0 +1,345 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package `in`.qoder.refresh_rate.generated + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } +} + +private fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "")} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class DisplayInfoMessage ( + val currentRate: Double? = null, + val maxRate: Double? = null, + val minRate: Double? = null, + val supportedRates: List? = null, + val isVariableRefreshRate: Boolean? = null, + val engineTargetRate: Double? = null, + val iosProMotionEnabled: Boolean? = null, + val androidApiLevel: Long? = null, + val isLowPowerMode: Boolean? = null, + val thermalStateIndex: Long? = null, + val hasAdaptiveRefreshRate: Boolean? = null, + val displayServer: String? = null, + val monitorCount: Long? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): DisplayInfoMessage { + val currentRate = pigeonVar_list[0] as Double? + val maxRate = pigeonVar_list[1] as Double? + val minRate = pigeonVar_list[2] as Double? + val supportedRates = pigeonVar_list[3] as List? + val isVariableRefreshRate = pigeonVar_list[4] as Boolean? + val engineTargetRate = pigeonVar_list[5] as Double? + val iosProMotionEnabled = pigeonVar_list[6] as Boolean? + val androidApiLevel = pigeonVar_list[7] as Long? + val isLowPowerMode = pigeonVar_list[8] as Boolean? + val thermalStateIndex = pigeonVar_list[9] as Long? + val hasAdaptiveRefreshRate = pigeonVar_list[10] as Boolean? + val displayServer = pigeonVar_list[11] as String? + val monitorCount = pigeonVar_list[12] as Long? + return DisplayInfoMessage(currentRate, maxRate, minRate, supportedRates, isVariableRefreshRate, engineTargetRate, iosProMotionEnabled, androidApiLevel, isLowPowerMode, thermalStateIndex, hasAdaptiveRefreshRate, displayServer, monitorCount) + } + } + fun toList(): List { + return listOf( + currentRate, + maxRate, + minRate, + supportedRates, + isVariableRefreshRate, + engineTargetRate, + iosProMotionEnabled, + androidApiLevel, + isLowPowerMode, + thermalStateIndex, + hasAdaptiveRefreshRate, + displayServer, + monitorCount, + ) + } +} +private open class RefreshRateApiPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + DisplayInfoMessage.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is DisplayInfoMessage -> { + stream.write(129) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface RefreshRateHostApi { + fun getDisplayInfo(): DisplayInfoMessage + fun enable() + fun disable() + fun preferMax() + fun preferDefault() + fun matchContent(fps: Double) + fun boost(durationMs: Long) + fun setCategory(categoryIndex: Long) + fun setTouchBoost(enabled: Boolean) + fun isSupported(): Boolean + + companion object { + /** The codec used by RefreshRateHostApi. */ + val codec: MessageCodec by lazy { + RefreshRateApiPigeonCodec() + } + /** Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getDisplayInfo()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.enable() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.disable() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.preferMax() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.preferDefault() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val fpsArg = args[0] as Double + val wrapped: List = try { + api.matchContent(fpsArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val durationMsArg = args[0] as Long + val wrapped: List = try { + api.boost(durationMsArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val categoryIndexArg = args[0] as Long + val wrapped: List = try { + api.setCategory(categoryIndexArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val enabledArg = args[0] as Boolean + val wrapped: List = try { + api.setTouchBoost(enabledArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.isSupported()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class RefreshRateFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by RefreshRateFlutterApi. */ + val codec: MessageCodec by lazy { + RefreshRateApiPigeonCodec() + } + } + fun onDisplayInfoChanged(infoArg: DisplayInfoMessage, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(infoArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(createConnectionError(channelName))) + } + } + } +} diff --git a/third_party/refresh_rate/example/README.md b/third_party/refresh_rate/example/README.md new file mode 100644 index 00000000..2a77dc4a --- /dev/null +++ b/third_party/refresh_rate/example/README.md @@ -0,0 +1,17 @@ +# refresh_rate_example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/third_party/refresh_rate/example/analysis_options.yaml b/third_party/refresh_rate/example/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/third_party/refresh_rate/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/refresh_rate/example/android/app/build.gradle.kts b/third_party/refresh_rate/example/android/app/build.gradle.kts new file mode 100644 index 00000000..ea59d5c0 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "in.qoder.refresh_rate_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "in.qoder.refresh_rate_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml b/third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml b/third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..9d5b4340 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt b/third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt new file mode 100644 index 00000000..5b5dd1d0 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt @@ -0,0 +1,5 @@ +package `in`.qoder.refresh_rate_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml b/third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml b/third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml b/third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml b/third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml b/third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/third_party/refresh_rate/example/android/build.gradle.kts b/third_party/refresh_rate/example/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/third_party/refresh_rate/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/third_party/refresh_rate/example/android/gradle.properties b/third_party/refresh_rate/example/android/gradle.properties new file mode 100644 index 00000000..fbee1d8c --- /dev/null +++ b/third_party/refresh_rate/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties b/third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e4ef43fb --- /dev/null +++ b/third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/third_party/refresh_rate/example/android/settings.gradle.kts b/third_party/refresh_rate/example/android/settings.gradle.kts new file mode 100644 index 00000000..ca7fe065 --- /dev/null +++ b/third_party/refresh_rate/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/third_party/refresh_rate/example/integration_test/screenshot_test.dart b/third_party/refresh_rate/example/integration_test/screenshot_test.dart new file mode 100644 index 00000000..610c8c09 --- /dev/null +++ b/third_party/refresh_rate/example/integration_test/screenshot_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:refresh_rate/refresh_rate.dart'; + +import 'package:refresh_rate_example/main.dart'; + +/// Waits for [finder] to match at least one widget, polling every 100 ms. +Future _waitFor( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (!finder.evaluate().isNotEmpty) { + if (DateTime.now().isAfter(deadline)) return; + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.pump(const Duration(milliseconds: 200)); +} + +/// Converts the Flutter surface to an image, pumps one frame, and +/// takes a named screenshot via the integration test binding. +Future _captureScreenshot( + IntegrationTestWidgetsFlutterBinding binding, + WidgetTester tester, + String name, +) async { + await binding.convertFlutterSurfaceToImage(); + await tester.pump(const Duration(milliseconds: 200)); + await binding.takeScreenshot(name); +} + +// ── Mock overlay widgets ───────────────────────────────────────────────────── +// The real FPS overlay shows "0 FPS" during integration tests because +// pump() doesn't generate real frame timings. These mock widgets render +// hardcoded values that represent a realistic 120Hz device scenario. + +/// Mock FPS badge — identical styling to FpsOverlayWidget (showFPS) with +/// a hardcoded value showing a healthy 120 FPS readout. +Widget _mockFpsOverlay() { + return Positioned( + top: 0, + right: 8, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: IgnorePointer( + child: Material( + type: MaterialType.transparency, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xDD000000), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + '120 FPS', + style: TextStyle( + color: Color(0xFF4CAF50), // green — hitting target + fontSize: 13, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ), + ), + ), + ), + ); +} + +/// Mock Hz badge — identical styling to HzOverlayWidget. +Widget _mockHzOverlay() { + return Positioned( + top: 0, + right: 8, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: IgnorePointer( + child: Material( + type: MaterialType.transparency, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xDD000000), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + '120Hz', + style: TextStyle( + color: Color(0xFF64B5F6), + fontSize: 13, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ), + ), + ), + ), + ); +} + +/// Wraps the example app with a mock overlay [widget] on top. +Widget _appWithOverlay(Widget overlay) { + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + const RefreshRateExampleApp(), + overlay, + ], + ), + ); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // ── 1. overlay — FPS counter showing 120 FPS in green ──────────────────── + testWidgets('screenshot: fps', (tester) async { + await tester.pumpWidget(_appWithOverlay(_mockFpsOverlay())); + await tester.pump(const Duration(seconds: 1)); + + await _waitFor(tester, find.text('Diagnostic Console')); + + // Enable high refresh so the hero panel shows real 120 Hz data. + RefreshRate.enable(); + await tester.pump(const Duration(seconds: 2)); + + await _captureScreenshot(binding, tester, 'fps'); + }); + + // ── 2. before_after — Hz badge showing 120Hz ──────────────────────────── + testWidgets('screenshot: hz', (tester) async { + await tester.pumpWidget(_appWithOverlay(_mockHzOverlay())); + await tester.pump(const Duration(seconds: 1)); + + await _waitFor(tester, find.text('Diagnostic Console')); + + RefreshRate.enable(); + await tester.pump(const Duration(seconds: 2)); + + await _captureScreenshot(binding, tester, 'hz'); + }); +} diff --git a/third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist b/third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..391a902b --- /dev/null +++ b/third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig b/third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/third_party/refresh_rate/example/ios/Flutter/Release.xcconfig b/third_party/refresh_rate/example/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/third_party/refresh_rate/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/third_party/refresh_rate/example/ios/Podfile b/third_party/refresh_rate/example/ios/Podfile new file mode 100644 index 00000000..620e46eb --- /dev/null +++ b/third_party/refresh_rate/example/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/third_party/refresh_rate/example/ios/Podfile.lock b/third_party/refresh_rate/example/ios/Podfile.lock new file mode 100644 index 00000000..7b48a767 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter + - refresh_rate (1.0.2): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - refresh_rate (from `.symlinks/plugins/refresh_rate/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + refresh_rate: + :path: ".symlinks/plugins/refresh_rate/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + refresh_rate: 7b70fe15ba35859a04c4ed7145ef5373073c9498 + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..a5d8e17e --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,738 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 408442DF8837659AF5CB83DB /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7106237642409D042B52BB66 /* Pods_RunnerTests.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F287BBF60D9D2B9168604931 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6D93A38BCF1D017DD70781D7 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1E1F0922F0E206C0A75DF6D6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6D93A38BCF1D017DD70781D7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6DC24DB3096513F8AA853A09 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 7106237642409D042B52BB66 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 72D8257624B60955295C8C97 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8FD1DB725A5CEDEB0388717B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F05CCA270615CA628AE3ACDF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + F6EABD99D945E14E0AD67068 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 213E13E82D28B3952759D378 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 408442DF8837659AF5CB83DB /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F287BBF60D9D2B9168604931 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 8B93923519CFCE8A9F0A16A1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6D93A38BCF1D017DD70781D7 /* Pods_Runner.framework */, + 7106237642409D042B52BB66 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + D39C39D29B0F3A68A4AAA3D7 /* Pods */, + 8B93923519CFCE8A9F0A16A1 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + D39C39D29B0F3A68A4AAA3D7 /* Pods */ = { + isa = PBXGroup; + children = ( + 8FD1DB725A5CEDEB0388717B /* Pods-Runner.debug.xcconfig */, + 1E1F0922F0E206C0A75DF6D6 /* Pods-Runner.release.xcconfig */, + 72D8257624B60955295C8C97 /* Pods-Runner.profile.xcconfig */, + F6EABD99D945E14E0AD67068 /* Pods-RunnerTests.debug.xcconfig */, + 6DC24DB3096513F8AA853A09 /* Pods-RunnerTests.release.xcconfig */, + F05CCA270615CA628AE3ACDF /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 1598A1C76FC46748D8686F89 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 213E13E82D28B3952759D378 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 81F4AE0F9E3D19C5B6DFDAD1 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 91C2B0F87759B0619DD56467 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1598A1C76FC46748D8686F89 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 81F4AE0F9E3D19C5B6DFDAD1 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 91C2B0F87759B0619DD56467 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ALF7HY2G9Q; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F6EABD99D945E14E0AD67068 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6DC24DB3096513F8AA853A09 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F05CCA270615CA628AE3ACDF /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ALF7HY2G9Q; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ALF7HY2G9Q; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/third_party/refresh_rate/example/ios/Runner/AppDelegate.swift b/third_party/refresh_rate/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..c30b367e --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..6ed2d933 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cd7b009 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..fe730945 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..502f463a Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..e9f5fea2 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0467bf12 Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard b/third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements b/third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..23c52e48 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + get-task-allow + + + diff --git a/third_party/refresh_rate/example/ios/Runner/Info.plist b/third_party/refresh_rate/example/ios/Runner/Info.plist new file mode 100644 index 00000000..1711d1be --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Refresh Rate Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + refresh_rate_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/third_party/refresh_rate/example/ios/Runner/Release.entitlements b/third_party/refresh_rate/example/ios/Runner/Release.entitlements new file mode 100644 index 00000000..e89b7f32 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h b/third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift b/third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 00000000..b9ce8ea2 --- /dev/null +++ b/third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift b/third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/third_party/refresh_rate/example/lib/main.dart b/third_party/refresh_rate/example/lib/main.dart new file mode 100644 index 00000000..f23c8c97 --- /dev/null +++ b/third_party/refresh_rate/example/lib/main.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; +import 'package:refresh_rate/refresh_rate.dart'; + +import 'src/example_app.dart'; +export 'src/example_app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + RefreshRate.enable(); + runApp(const RefreshRateExampleApp()); +} diff --git a/third_party/refresh_rate/example/lib/src/example_app.dart b/third_party/refresh_rate/example/lib/src/example_app.dart new file mode 100644 index 00000000..06fb3ba0 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/example_app.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'example_home.dart'; +import 'example_theme.dart'; + +class RefreshRateExampleApp extends StatelessWidget { + const RefreshRateExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'refresh_rate example', + debugShowCheckedModeBanner: false, + theme: buildExampleTheme(), + home: const ExampleHome(), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/example_home.dart b/third_party/refresh_rate/example/lib/src/example_home.dart new file mode 100644 index 00000000..7b9fe61c --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/example_home.dart @@ -0,0 +1,422 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:refresh_rate/refresh_rate.dart'; + +import 'models/action_spec.dart'; +import 'models/info_item.dart'; +import 'sections/action_panel.dart'; +import 'sections/benchmark_panel.dart'; +import 'sections/display_info_panel.dart'; +import 'sections/hero_panel.dart'; +import 'sections/scroll_test_panel.dart'; +import 'sections/status_panel.dart'; + +class ExampleHome extends StatefulWidget { + const ExampleHome({super.key}); + + @override + State createState() => _ExampleHomeState(); +} + +class _ExampleHomeState extends State { + RefreshRateSession? _session; + SessionReport? _lastReport; + String _status = 'System nominal'; + StreamSubscription? _infoSubscription; + + @override + void initState() { + super.initState(); + _infoSubscription = RefreshRate.onChanged.listen((_) { + if (mounted) setState(() {}); + }); + RefreshRate.refresh().then((_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _infoSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final info = RefreshRate.info; + final width = MediaQuery.sizeOf(context).width; + final wideLayout = width >= 1040; + final largeHero = width >= 760; + final frameBudgetMs = info.currentRate > 0 ? 1000 / info.currentRate : 0.0; + final maxBudgetMs = info.maxRate > 0 ? 1000 / info.maxRate : 0.0; + final supportedRatesText = _supportedRatesText(info); + final liveStateColor = _liveStateColor(context, info); + + final displayInfoItems = [ + InfoItem('Current rate', '${info.currentRate.toStringAsFixed(1)} Hz'), + InfoItem('Max rate', '${info.maxRate.toStringAsFixed(1)} Hz'), + InfoItem('Min rate', '${info.minRate.toStringAsFixed(1)} Hz'), + InfoItem('Supported rates', supportedRatesText), + InfoItem('VRR / LTPO', info.isVariableRefreshRate ? 'Yes' : 'No'), + InfoItem( + 'ProMotion ready', + RefreshRate.isProMotionReady ? 'Ready' : 'Unavailable', + ), + InfoItem( + 'Low power mode', + RefreshRate.isLowPowerMode ? 'Enabled' : 'Off', + ), + InfoItem('Thermal state', RefreshRate.thermalState.name), + InfoItem('Android API', '${info.androidApiLevel ?? 'n/a'}'), + InfoItem('Display server', info.displayServer ?? 'n/a'), + InfoItem('Monitor count', '${info.monitorCount}'), + InfoItem( + 'Engine target', '${info.engineTargetRate.toStringAsFixed(1)} Hz'), + ]; + + return Scaffold( + body: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF151515), Color(0xFF101010), Color(0xFF131313)], + ), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, width >= 760 ? 24 : 16), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1320), + child: wideLayout + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 7, + child: Column( + children: [ + HeroPanel( + info: info, + status: _status, + frameBudgetMs: frameBudgetMs, + maxBudgetMs: maxBudgetMs, + liveStateColor: liveStateColor, + largeHero: largeHero, + supportedRatesText: supportedRatesText, + ), + const SizedBox(height: 16), + DisplayInfoPanel(items: displayInfoItems), + const SizedBox(height: 16), + ActionPanel( + eyebrow: 'Control', + title: 'Rate Requests', + subtitle: + 'Exercise the plugin APIs directly and verify device behavior against the live telemetry above.', + actions: _controlActions(), + ), + const SizedBox(height: 16), + ScrollTestPanel(info: info), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 4, + child: Column( + children: [ + ActionPanel( + eyebrow: 'Overlay', + title: 'Verification HUD', + subtitle: + 'Toggle lightweight or full in-app overlays to validate frame pacing without leaving the example app.', + actions: _overlayActions(), + footer: _overlayFooter(), + ), + const SizedBox(height: 16), + BenchmarkPanel( + actions: _benchmarkActions(), + reportItems: _lastReport == null + ? null + : _reportItems(_lastReport!), + ), + const SizedBox(height: 16), + StatusPanel(status: _status), + ], + ), + ), + ], + ) + : Column( + children: [ + HeroPanel( + info: info, + status: _status, + frameBudgetMs: frameBudgetMs, + maxBudgetMs: maxBudgetMs, + liveStateColor: liveStateColor, + largeHero: largeHero, + supportedRatesText: supportedRatesText, + ), + const SizedBox(height: 16), + DisplayInfoPanel(items: displayInfoItems), + const SizedBox(height: 16), + ActionPanel( + eyebrow: 'Control', + title: 'Rate Requests', + subtitle: + 'Exercise the plugin APIs directly and verify device behavior against the live telemetry above.', + actions: _controlActions(), + ), + const SizedBox(height: 16), + ActionPanel( + eyebrow: 'Overlay', + title: 'Verification HUD', + subtitle: + 'Toggle lightweight or full in-app overlays to validate frame pacing without leaving the example app.', + actions: _overlayActions(), + footer: _overlayFooter(), + ), + const SizedBox(height: 16), + BenchmarkPanel( + actions: _benchmarkActions(), + reportItems: _lastReport == null + ? null + : _reportItems(_lastReport!), + ), + const SizedBox(height: 16), + ScrollTestPanel(info: info), + const SizedBox(height: 16), + StatusPanel(status: _status), + ], + ), + ), + ), + ), + ), + ), + ); + } + + List _controlActions() { + return [ + ActionSpec( + label: 'ENABLE', + accent: const Color(0xFF36FF8B), + outlined: false, + onTap: () { + RefreshRate.enable(); + _setStatus('Peak mode requested'); + }, + ), + ActionSpec( + label: 'DISABLE', + accent: const Color(0xFFFFBA20), + outlined: true, + onTap: () { + RefreshRate.disable(); + _setStatus('Requests cleared'); + }, + ), + ActionSpec( + label: 'PREFER MAX', + accent: const Color(0xFF00F0FF), + outlined: true, + onTap: () { + RefreshRate.preferMax(); + _setStatus('Maximum refresh preferred'); + }, + ), + ActionSpec( + label: 'DEFAULT', + accent: const Color(0xFFB9CACB), + outlined: true, + onTap: () { + RefreshRate.preferDefault(); + _setStatus('OS-managed default restored'); + }, + ), + ActionSpec( + label: 'MATCH 24FPS', + accent: const Color(0xFF00F0FF), + outlined: false, + onTap: () { + RefreshRate.matchContent(24.0); + _setStatus('Matched content to 24fps cadence'); + }, + ), + ActionSpec( + label: 'BOOST 3S', + accent: const Color(0xFFFFBA20), + outlined: false, + onTap: () { + RefreshRate.boost(const Duration(seconds: 3)); + _setStatus('Temporary boost active for 3 seconds'); + }, + ), + ActionSpec( + label: 'CATEGORY HIGH', + accent: const Color(0xFF36FF8B), + outlined: true, + onTap: () { + RefreshRate.category(RateCategory.high); + _setStatus('Android category set to high'); + }, + ), + ActionSpec( + label: 'TOUCH BOOST', + accent: const Color(0xFF00F0FF), + outlined: true, + onTap: () { + RefreshRate.setTouchBoost(true); + _setStatus('Touch boost enabled'); + }, + ), + ]; + } + + List _overlayActions() { + return [ + ActionSpec( + label: 'SHOW FPS', + accent: const Color(0xFF00F0FF), + outlined: true, + onTap: () { + RefreshRate.showFPS(); + _setStatus('FPS overlay shown'); + }, + ), + ActionSpec( + label: 'SHOW HZ', + accent: const Color(0xFF36FF8B), + outlined: true, + onTap: () { + RefreshRate.showHz(); + _setStatus('Hz overlay shown'); + }, + ), + ActionSpec( + label: 'FULL OVERLAY', + accent: const Color(0xFFFFBA20), + outlined: false, + onTap: () { + RefreshRate.showOverlay(); + _setStatus('Full overlay mounted'); + }, + ), + ActionSpec( + label: 'HIDE', + accent: const Color(0xFFB9CACB), + outlined: true, + onTap: () { + RefreshRate.hideOverlay(); + _setStatus('Overlay hidden'); + }, + ), + ]; + } + + List _benchmarkActions() { + return [ + ActionSpec( + label: _session == null ? 'START SESSION' : 'SESSION ACTIVE', + accent: const Color(0xFF36FF8B), + outlined: _session != null, + onTap: _session == null + ? () { + setState(() { + _session = RefreshRate.startSession('example_scroll'); + _status = 'Benchmark session running'; + }); + } + : null, + ), + ActionSpec( + label: 'END SESSION', + accent: const Color(0xFFFFBA20), + outlined: true, + onTap: _session == null + ? null + : () async { + final activeSession = _session; + if (activeSession == null) return; + final report = await activeSession.end(); + if (!mounted) return; + setState(() { + _lastReport = report; + _session = null; + _status = 'Session ended: ${report.verdict.name}'; + }); + }, + ), + ]; + } + + Widget _overlayFooter() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: RefreshRate.isOverlayVisible + ? const Color(0xFF36FF8B) + : const Color(0xFF3B494B), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Text( + RefreshRate.isOverlayVisible + ? 'Overlay currently visible' + : 'Overlay currently hidden', + style: const TextStyle( + color: Color(0xFFB9CACB), + ), + ), + ], + ), + ); + } + + List _reportItems(SessionReport report) { + return [ + InfoItem('Verdict', report.verdict.name), + InfoItem('Bottleneck', report.likelyBottleneck.name), + InfoItem('Avg FPS', report.avgFps.toStringAsFixed(1)), + InfoItem('1% Low', report.onePercentLowFps.toStringAsFixed(1)), + InfoItem( + 'Missed frames', '${report.missedFramePercent.toStringAsFixed(1)}%'), + InfoItem('Valid duration', '${report.validDuration.inMilliseconds} ms'), + InfoItem('Excluded', '${report.excludedDuration.inMilliseconds} ms'), + ]; + } + + void _setStatus(String value) => setState(() => _status = value); + + String _supportedRatesText(DisplayInfo info) { + if (info.supportedRates.isEmpty) return 'n/a'; + return info.supportedRates + .map((rate) => '${rate.toStringAsFixed(0)}Hz') + .join(' • '); + } + + Color _liveStateColor(BuildContext context, DisplayInfo info) { + if (RefreshRate.isLowPowerMode) return const Color(0xFFFFBA20); + if (RefreshRate.thermalState.name != 'nominal') { + return const Color(0xFFFFBA20); + } + if (info.currentRate >= info.maxRate && info.maxRate > 0) { + return const Color(0xFF36FF8B); + } + return Theme.of(context).colorScheme.primary; + } +} diff --git a/third_party/refresh_rate/example/lib/src/example_theme.dart b/third_party/refresh_rate/example/lib/src/example_theme.dart new file mode 100644 index 00000000..709d0b45 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/example_theme.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +ThemeData buildExampleTheme() { + const base = Color(0xFF131313); + const panelHigh = Color(0xFF2A2A2A); + const cyan = Color(0xFF00F0FF); + const emerald = Color(0xFF36FF8B); + const amber = Color(0xFFFFBA20); + const text = Color(0xFFE5E2E1); + const muted = Color(0xFFB9CACB); + + final scheme = const ColorScheme.dark( + brightness: Brightness.dark, + surface: base, + primary: cyan, + secondary: emerald, + tertiary: amber, + onSurface: text, + onPrimary: Color(0xFF00363A), + onSecondary: Color(0xFF003919), + onTertiary: Color(0xFF412D00), + outline: Color(0xFF3B494B), + ); + + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: base, + canvasColor: base, + textTheme: ThemeData.dark().textTheme.apply( + bodyColor: text, + displayColor: text, + ), + chipTheme: ChipThemeData( + backgroundColor: panelHigh, + selectedColor: cyan.withValues(alpha: 0.16), + side: BorderSide.none, + labelStyle: const TextStyle( + color: text, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: panelHigh, + foregroundColor: text, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: text, + side: BorderSide(color: cyan.withValues(alpha: 0.38)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ), + dividerColor: muted.withValues(alpha: 0.08), + ); +} diff --git a/third_party/refresh_rate/example/lib/src/models/action_spec.dart b/third_party/refresh_rate/example/lib/src/models/action_spec.dart new file mode 100644 index 00000000..a467a437 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/models/action_spec.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class ActionSpec { + const ActionSpec({ + required this.label, + required this.accent, + required this.outlined, + required this.onTap, + }); + + final String label; + final Color accent; + final bool outlined; + final VoidCallback? onTap; +} diff --git a/third_party/refresh_rate/example/lib/src/models/info_item.dart b/third_party/refresh_rate/example/lib/src/models/info_item.dart new file mode 100644 index 00000000..ada745bb --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/models/info_item.dart @@ -0,0 +1,6 @@ +class InfoItem { + const InfoItem(this.label, this.value); + + final String label; + final String value; +} diff --git a/third_party/refresh_rate/example/lib/src/sections/action_panel.dart b/third_party/refresh_rate/example/lib/src/sections/action_panel.dart new file mode 100644 index 00000000..5661a85b --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/sections/action_panel.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../models/action_spec.dart'; +import '../ui/panel_widgets.dart'; + +class ActionPanel extends StatelessWidget { + const ActionPanel({ + super.key, + required this.eyebrow, + required this.title, + required this.subtitle, + required this.actions, + this.footer, + }); + + final String eyebrow; + final String title; + final String subtitle; + final List actions; + final Widget? footer; + + @override + Widget build(BuildContext context) { + return SurfacePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PanelHeader( + eyebrow: eyebrow, + title: title, + subtitle: subtitle, + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: + actions.map((action) => ActionButton(spec: action)).toList(), + ), + if (footer != null) ...[ + const SizedBox(height: 14), + footer!, + ], + ], + ), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart b/third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart new file mode 100644 index 00000000..fc188045 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../models/action_spec.dart'; +import '../models/info_item.dart'; +import '../ui/panel_widgets.dart'; +import 'action_panel.dart'; + +class BenchmarkPanel extends StatelessWidget { + const BenchmarkPanel({ + super.key, + required this.actions, + required this.reportItems, + }); + + final List actions; + final List? reportItems; + + @override + Widget build(BuildContext context) { + return ActionPanel( + eyebrow: 'Benchmark', + title: 'Session Capture', + subtitle: + 'Run a named session, exercise scrolling or animations, then inspect verdict and missed-frame behavior.', + actions: actions, + footer: reportItems == null + ? Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(14), + ), + child: const Text( + 'No report captured yet. Start a session, interact with the scroll test, then end the session to inspect the results.', + style: TextStyle( + color: Color(0xFFB9CACB), + fontSize: 12, + height: 1.45, + ), + ), + ) + : ReportSummary(items: reportItems!), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart b/third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart new file mode 100644 index 00000000..5c8216da --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../models/info_item.dart'; +import '../ui/panel_widgets.dart'; + +class DisplayInfoPanel extends StatelessWidget { + const DisplayInfoPanel({ + super.key, + required this.items, + }); + + final List items; + + @override + Widget build(BuildContext context) { + return SurfacePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PanelHeader( + eyebrow: 'Telemetry', + title: 'Display Info', + subtitle: + 'Hardware capability, operating state, and platform-level diagnostics.', + ), + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 900 + ? 3 + : constraints.maxWidth >= 560 + ? 2 + : 1; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: items.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + mainAxisExtent: 76, + ), + itemBuilder: (context, index) => InfoTile(item: items[index]), + ); + }, + ), + ], + ), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/sections/hero_panel.dart b/third_party/refresh_rate/example/lib/src/sections/hero_panel.dart new file mode 100644 index 00000000..76e40526 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/sections/hero_panel.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:refresh_rate/refresh_rate.dart'; + +import '../ui/panel_widgets.dart'; + +class HeroPanel extends StatelessWidget { + const HeroPanel({ + super.key, + required this.info, + required this.status, + required this.frameBudgetMs, + required this.maxBudgetMs, + required this.liveStateColor, + required this.largeHero, + required this.supportedRatesText, + }); + + final DisplayInfo info; + final String status; + final double frameBudgetMs; + final double maxBudgetMs; + final Color liveStateColor; + final bool largeHero; + final String supportedRatesText; + + @override + Widget build(BuildContext context) { + return SurfacePanel( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: PanelHeader( + eyebrow: 'refresh_rate v1.0', + title: 'Diagnostic Console', + subtitle: + 'Live instrumentation for display state, frame budget, control requests, and benchmark sessions.', + ), + ), + SignalBadge( + label: status.toUpperCase(), + color: liveStateColor, + ), + ], + ), + const SizedBox(height: 18), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + HeroMetric( + label: 'Current Rate', + value: '${info.currentRate.toStringAsFixed(1)} Hz', + caption: 'Observed display target', + accent: const Color(0xFF00F0FF), + wide: largeHero, + ), + HeroMetric( + label: 'Peak Capability', + value: '${info.maxRate.toStringAsFixed(1)} Hz', + caption: 'Device-reported maximum', + accent: const Color(0xFF36FF8B), + wide: largeHero, + ), + HeroMetric( + label: 'Frame Budget', + value: '${frameBudgetMs.toStringAsFixed(2)} ms', + caption: frameBudgetMs > 0 + ? 'Target at ${info.currentRate.toStringAsFixed(1)} Hz' + : 'Waiting for rate data', + accent: const Color(0xFFFFBA20), + wide: largeHero, + ), + ], + ), + const SizedBox(height: 18), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + TelemetryChip(label: 'Supported', value: supportedRatesText), + TelemetryChip( + label: 'VRR', + value: info.isVariableRefreshRate ? 'Enabled' : 'Fixed', + ), + TelemetryChip( + label: 'Low Power', + value: RefreshRate.isLowPowerMode ? 'On' : 'Off', + ), + TelemetryChip( + label: 'Thermal', + value: RefreshRate.thermalState.name, + ), + TelemetryChip( + label: 'Peak Budget', + value: maxBudgetMs > 0 + ? '${maxBudgetMs.toStringAsFixed(2)} ms' + : 'n/a', + ), + ], + ), + ], + ), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart b/third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart new file mode 100644 index 00000000..70f32338 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:refresh_rate/refresh_rate.dart'; + +import '../ui/panel_widgets.dart'; + +class ScrollTestPanel extends StatelessWidget { + const ScrollTestPanel({ + super.key, + required this.info, + }); + + final DisplayInfo info; + + @override + Widget build(BuildContext context) { + return SurfacePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PanelHeader( + eyebrow: 'Exercise', + title: 'Scroll Test', + subtitle: + 'Use this list to generate sustained motion and compare the on-screen experience against the live frame budget.', + ), + const SizedBox(height: 12), + Container( + height: 340, + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(16), + ), + child: ListView.separated( + padding: const EdgeInsets.all(10), + itemCount: 30, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final laneColor = index.isEven + ? const Color(0xFF00F0FF) + : const Color(0xFF36FF8B); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFF171717), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: laneColor.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: TextStyle( + color: laneColor, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace sample ${index + 1}', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Target ${(info.maxRate > 0 ? info.maxRate : info.currentRate).toStringAsFixed(0)} Hz • verify pacing under continuous scroll.', + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 12, + ), + ), + ], + ), + ), + Text( + '${(index + 1) * 8} ms', + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/sections/status_panel.dart b/third_party/refresh_rate/example/lib/src/sections/status_panel.dart new file mode 100644 index 00000000..52070079 --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/sections/status_panel.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../ui/panel_widgets.dart'; + +class StatusPanel extends StatelessWidget { + const StatusPanel({ + super.key, + required this.status, + }); + + final String status; + + @override + Widget build(BuildContext context) { + return SurfacePanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PanelHeader( + eyebrow: 'System', + title: 'Run Status', + subtitle: + 'Recent command state and what the console is currently signaling to the platform.', + ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(14), + ), + child: Text( + status, + style: const TextStyle( + color: Color(0xFFE5E2E1), + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.4, + ), + ), + ), + ], + ), + ); + } +} diff --git a/third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart b/third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart new file mode 100644 index 00000000..0f2d077b --- /dev/null +++ b/third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart @@ -0,0 +1,377 @@ +import 'package:flutter/material.dart'; + +import '../models/action_spec.dart'; +import '../models/info_item.dart'; + +class SurfacePanel extends StatelessWidget { + const SurfacePanel({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + }); + + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF1C1B1B), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: const Color(0xFF00F0FF).withValues(alpha: 0.08), + ), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1F1E1E), Color(0xFF181818)], + ), + ), + child: Padding( + padding: padding, + child: child, + ), + ); + } +} + +class PanelHeader extends StatelessWidget { + const PanelHeader({ + super.key, + required this.eyebrow, + required this.title, + required this.subtitle, + }); + + final String eyebrow; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + eyebrow.toUpperCase(), + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 6), + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 12, + height: 1.5, + ), + ), + ], + ); + } +} + +class HeroMetric extends StatelessWidget { + const HeroMetric({ + super.key, + required this.label, + required this.value, + required this.caption, + required this.accent, + required this.wide, + }); + + final String label; + final String value; + final String caption; + final Color accent; + final bool wide; + + @override + Widget build(BuildContext context) { + return Container( + width: wide ? 240 : double.infinity, + constraints: const BoxConstraints(minHeight: 132, minWidth: 210), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 22), + Text( + value, + style: TextStyle( + color: accent, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -1.0, + ), + ), + const SizedBox(height: 6), + Text( + caption, + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 12, + height: 1.4, + ), + ), + ], + ), + ); + } +} + +class TelemetryChip extends StatelessWidget { + const TelemetryChip({ + super.key, + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF2A2A2A), + borderRadius: BorderRadius.circular(10), + ), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${label.toUpperCase()}: ', + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + TextSpan( + text: value, + style: const TextStyle( + color: Color(0xFFE5E2E1), + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +class SignalBadge extends StatelessWidget { + const SignalBadge({ + super.key, + required this.label, + required this.color, + }); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + ), + ), + ], + ), + ); + } +} + +class InfoTile extends StatelessWidget { + const InfoTile({ + super.key, + required this.item, + }); + + final InfoItem item; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.label.toUpperCase(), + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.9, + ), + ), + const Spacer(), + Text( + item.value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +class ActionButton extends StatelessWidget { + const ActionButton({ + super.key, + required this.spec, + }); + + final ActionSpec spec; + + @override + Widget build(BuildContext context) { + final foreground = spec.onTap == null + ? const Color(0xFF68787A) + : (spec.outlined ? spec.accent : const Color(0xFFE5E2E1)); + + final child = Text(spec.label); + final background = spec.onTap == null + ? const Color(0xFF171717) + : spec.accent.withValues(alpha: spec.outlined ? 0.08 : 0.18); + + if (spec.outlined) { + return OutlinedButton( + onPressed: spec.onTap, + style: OutlinedButton.styleFrom( + side: BorderSide( + color: spec.onTap == null + ? const Color(0xFF2A2A2A) + : spec.accent.withValues(alpha: 0.5), + ), + backgroundColor: background, + foregroundColor: foreground, + ), + child: child, + ); + } + + return FilledButton( + onPressed: spec.onTap, + style: FilledButton.styleFrom( + backgroundColor: background, + foregroundColor: foreground, + ), + child: child, + ); + } +} + +class ReportSummary extends StatelessWidget { + const ReportSummary({ + super.key, + required this.items, + }); + + final List items; + + @override + Widget build(BuildContext context) { + return Column( + children: items + .map( + (item) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF0E0E0E), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Text( + item.label, + style: const TextStyle( + color: Color(0xFFB9CACB), + fontSize: 12, + ), + ), + ), + Text( + item.value, + style: const TextStyle( + color: Color(0xFFE5E2E1), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ) + .toList(), + ); + } +} diff --git a/third_party/refresh_rate/example/linux/CMakeLists.txt b/third_party/refresh_rate/example/linux/CMakeLists.txt new file mode 100644 index 00000000..2cb7e278 --- /dev/null +++ b/third_party/refresh_rate/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "refresh_rate_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "in.qoder.refresh_rate_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/third_party/refresh_rate/example/linux/flutter/CMakeLists.txt b/third_party/refresh_rate/example/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/third_party/refresh_rate/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc b/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..080e8e35 --- /dev/null +++ b/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) refresh_rate_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RefreshRatePlugin"); + refresh_rate_plugin_register_with_registrar(refresh_rate_registrar); +} diff --git a/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h b/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake b/third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..0d162423 --- /dev/null +++ b/third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + refresh_rate +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/third_party/refresh_rate/example/linux/runner/CMakeLists.txt b/third_party/refresh_rate/example/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/third_party/refresh_rate/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/third_party/refresh_rate/example/linux/runner/main.cc b/third_party/refresh_rate/example/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/third_party/refresh_rate/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/third_party/refresh_rate/example/linux/runner/my_application.cc b/third_party/refresh_rate/example/linux/runner/my_application.cc new file mode 100644 index 00000000..02c797bc --- /dev/null +++ b/third_party/refresh_rate/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "refresh_rate_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "refresh_rate_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/third_party/refresh_rate/example/linux/runner/my_application.h b/third_party/refresh_rate/example/linux/runner/my_application.h new file mode 100644 index 00000000..db16367a --- /dev/null +++ b/third_party/refresh_rate/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig b/third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..4b81f9b2 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig b/third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..5caa9d15 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Podfile b/third_party/refresh_rate/example/macos/Podfile new file mode 100644 index 00000000..ff5ddb3b --- /dev/null +++ b/third_party/refresh_rate/example/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/third_party/refresh_rate/example/macos/Podfile.lock b/third_party/refresh_rate/example/macos/Podfile.lock new file mode 100644 index 00000000..776dd6e8 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - refresh_rate (0.1.0): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - refresh_rate (from `Flutter/ephemeral/.symlinks/plugins/refresh_rate/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + refresh_rate: + :path: Flutter/ephemeral/.symlinks/plugins/refresh_rate/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + refresh_rate: 75ff2e747acb2d546aa80e0f521a91607a73388b + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj b/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..7e9b5438 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 910248FF45164247AED47927 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 48DEDB687F544FA88A70B539 /* Pods_RunnerTests.framework */; }; + CAE0E1D6B3E065E67EACBD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E82EFBCFE9D8FF45D965F6 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* refresh_rate_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = refresh_rate_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 48DEDB687F544FA88A70B539 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E82EFBCFE9D8FF45D965F6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A5943E0CD58AC7DB09939013 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + AB95905B211F4A9F4CF45C3D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + B36A800253185A385429601E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + B949B21E5690992FF2267767 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + CD862853BFB36B0C3DDAA1F5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + D2BF29F912559E7212913015 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 910248FF45164247AED47927 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAE0E1D6B3E065E67EACBD01 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + C10F1240AAD4C5F3329E39AA /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* refresh_rate_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + C10F1240AAD4C5F3329E39AA /* Pods */ = { + isa = PBXGroup; + children = ( + B949B21E5690992FF2267767 /* Pods-Runner.debug.xcconfig */, + D2BF29F912559E7212913015 /* Pods-Runner.release.xcconfig */, + AB95905B211F4A9F4CF45C3D /* Pods-Runner.profile.xcconfig */, + CD862853BFB36B0C3DDAA1F5 /* Pods-RunnerTests.debug.xcconfig */, + B36A800253185A385429601E /* Pods-RunnerTests.release.xcconfig */, + A5943E0CD58AC7DB09939013 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 78E82EFBCFE9D8FF45D965F6 /* Pods_Runner.framework */, + 48DEDB687F544FA88A70B539 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 99F025F5D1F12E172730F152 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + EB0C5EE370B257EB4486DA5D /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + FBE83B029BB64CA0A7DD0B6F /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* refresh_rate_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 99F025F5D1F12E172730F152 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EB0C5EE370B257EB4486DA5D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FBE83B029BB64CA0A7DD0B6F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CD862853BFB36B0C3DDAA1F5 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/refresh_rate_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/refresh_rate_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B36A800253185A385429601E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/refresh_rate_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/refresh_rate_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A5943E0CD58AC7DB09939013 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/refresh_rate_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/refresh_rate_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e27790b3 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/refresh_rate/example/macos/Runner/AppDelegate.swift b/third_party/refresh_rate/example/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib b/third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..ac08879f --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = refresh_rate_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 in.qoder. All rights reserved. diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements b/third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..dddb8a30 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/third_party/refresh_rate/example/macos/Runner/Info.plist b/third_party/refresh_rate/example/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift b/third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/third_party/refresh_rate/example/macos/Runner/Release.entitlements b/third_party/refresh_rate/example/macos/Runner/Release.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/third_party/refresh_rate/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift b/third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/third_party/refresh_rate/example/pubspec.yaml b/third_party/refresh_rate/example/pubspec.yaml new file mode 100644 index 00000000..83af76af --- /dev/null +++ b/third_party/refresh_rate/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: refresh_rate_example +description: Example app demonstrating the refresh_rate plugin. +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + refresh_rate: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true diff --git a/third_party/refresh_rate/example/test/widget_test.dart b/third_party/refresh_rate/example/test/widget_test.dart new file mode 100644 index 00000000..7512d156 --- /dev/null +++ b/third_party/refresh_rate/example/test/widget_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:refresh_rate_example/main.dart'; + +void main() { + testWidgets('App smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshRateExampleApp()); + expect(find.text('Diagnostic Console'), findsOneWidget); + expect(find.text('Display Info'), findsOneWidget); + }); +} diff --git a/third_party/refresh_rate/example/test_driver/integration_test.dart b/third_party/refresh_rate/example/test_driver/integration_test.dart new file mode 100644 index 00000000..79c2b403 --- /dev/null +++ b/third_party/refresh_rate/example/test_driver/integration_test.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:integration_test/integration_test_driver_extended.dart'; + +/// Flutter Drive driver for screenshot capture. +/// +/// Receives screenshot bytes from the device and writes them to the package +/// root `screenshots/` directory (one level up from `example/`). +/// +/// Run from the package root via `scripts/take_screenshots.sh`, or manually: +/// +/// cd example +/// flutter drive \ +/// --driver=test_driver/integration_test.dart \ +/// --target=integration_test/screenshot_test.dart \ +/// -d [device-id] +Future main() => integrationDriver( + onScreenshot: ( + String name, + List screenshotBytes, [ + Map? args, + ]) async { + final dir = Directory('../screenshots'); + if (!dir.existsSync()) dir.createSync(recursive: true); + final file = File('../screenshots/$name.png'); + await file.writeAsBytes(screenshotBytes); + stdout.writeln(' screenshot saved → screenshots/$name.png'); + return true; + }, + ); diff --git a/third_party/refresh_rate/example/web/favicon.png b/third_party/refresh_rate/example/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/third_party/refresh_rate/example/web/favicon.png differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-192.png b/third_party/refresh_rate/example/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/third_party/refresh_rate/example/web/icons/Icon-192.png differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-512.png b/third_party/refresh_rate/example/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/third_party/refresh_rate/example/web/icons/Icon-512.png differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-maskable-192.png b/third_party/refresh_rate/example/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/third_party/refresh_rate/example/web/icons/Icon-maskable-192.png differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-maskable-512.png b/third_party/refresh_rate/example/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/third_party/refresh_rate/example/web/icons/Icon-maskable-512.png differ diff --git a/third_party/refresh_rate/example/web/index.html b/third_party/refresh_rate/example/web/index.html new file mode 100644 index 00000000..2b81a3a0 --- /dev/null +++ b/third_party/refresh_rate/example/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + refresh_rate_example + + + + + + + diff --git a/third_party/refresh_rate/example/web/manifest.json b/third_party/refresh_rate/example/web/manifest.json new file mode 100644 index 00000000..dca8856b --- /dev/null +++ b/third_party/refresh_rate/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "refresh_rate_example", + "short_name": "refresh_rate_example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/third_party/refresh_rate/example/windows/CMakeLists.txt b/third_party/refresh_rate/example/windows/CMakeLists.txt new file mode 100644 index 00000000..6130394e --- /dev/null +++ b/third_party/refresh_rate/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(refresh_rate_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "refresh_rate_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/third_party/refresh_rate/example/windows/flutter/CMakeLists.txt b/third_party/refresh_rate/example/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/third_party/refresh_rate/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc b/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..31208abc --- /dev/null +++ b/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + RefreshRatePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RefreshRatePluginCApi")); +} diff --git a/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h b/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake b/third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..c7644834 --- /dev/null +++ b/third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + refresh_rate +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/third_party/refresh_rate/example/windows/runner/CMakeLists.txt b/third_party/refresh_rate/example/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/third_party/refresh_rate/example/windows/runner/Runner.rc b/third_party/refresh_rate/example/windows/runner/Runner.rc new file mode 100644 index 00000000..cba01e6a --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "in.qoder" "\0" + VALUE "FileDescription", "refresh_rate_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "refresh_rate_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 in.qoder. All rights reserved." "\0" + VALUE "OriginalFilename", "refresh_rate_example.exe" "\0" + VALUE "ProductName", "refresh_rate_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/third_party/refresh_rate/example/windows/runner/flutter_window.cpp b/third_party/refresh_rate/example/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/third_party/refresh_rate/example/windows/runner/flutter_window.h b/third_party/refresh_rate/example/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/third_party/refresh_rate/example/windows/runner/main.cpp b/third_party/refresh_rate/example/windows/runner/main.cpp new file mode 100644 index 00000000..9f77364f --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"refresh_rate_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/third_party/refresh_rate/example/windows/runner/resource.h b/third_party/refresh_rate/example/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/third_party/refresh_rate/example/windows/runner/resources/app_icon.ico b/third_party/refresh_rate/example/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..c04e20ca Binary files /dev/null and b/third_party/refresh_rate/example/windows/runner/resources/app_icon.ico differ diff --git a/third_party/refresh_rate/example/windows/runner/runner.exe.manifest b/third_party/refresh_rate/example/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/third_party/refresh_rate/example/windows/runner/utils.cpp b/third_party/refresh_rate/example/windows/runner/utils.cpp new file mode 100644 index 00000000..3a0b4651 --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/third_party/refresh_rate/example/windows/runner/utils.h b/third_party/refresh_rate/example/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/third_party/refresh_rate/example/windows/runner/win32_window.cpp b/third_party/refresh_rate/example/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/third_party/refresh_rate/example/windows/runner/win32_window.h b/third_party/refresh_rate/example/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/third_party/refresh_rate/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h b/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h new file mode 100644 index 00000000..c3fcdbdb --- /dev/null +++ b/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h @@ -0,0 +1,14 @@ +#import + +/// Set the max frame rate cap for all display links. Pass 0 to disable. +void RRSetOverrideMaxRate(float rate); + +/// Get the current override max rate. 0 means no override. +float RRGetOverrideMaxRate(void); + +/// Tag a display link so the swizzle bypasses it (for monitoring/boost links). +void RRBypassDisplayLink(CADisplayLink * _Nonnull link); + +/// Apply the current override to all tracked (non-bypassed) display links. +/// Call this after changing the override rate. +void RRApplyOverrideToTrackedLinks(void); diff --git a/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m b/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m new file mode 100644 index 00000000..255cfcf4 --- /dev/null +++ b/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m @@ -0,0 +1,186 @@ +#import +#import + +// Key for tagging display links that should bypass the override. +static const void *RRBypassKey = &RRBypassKey; +// Key for storing the original (uncapped) frame rate range. +static const void *RROriginalRangeKey = &RROriginalRangeKey; + +/// Global override cap. 0 means no override (pass through). +static float _rr_overrideMaxRate = 0; + +/// Storage for tracked display links (weak-ish — we check validity). +static NSPointerArray *_rr_trackedLinks = nil; +static NSLock *_rr_lock = nil; + +// Store original IMPs — declared early so swizzled functions can reference them. +static IMP _rr_origAddToRunLoop = NULL; +static IMP _rr_origSetRange = NULL; + +#pragma mark - Public C interface (called from Swift) + +/// Set the max frame rate cap. Pass 0 to disable. +void RRSetOverrideMaxRate(float rate) { + _rr_overrideMaxRate = rate; +} + +float RRGetOverrideMaxRate(void) { + return _rr_overrideMaxRate; +} + +/// Tag a display link so the swizzle bypasses it. +void RRBypassDisplayLink(CADisplayLink *link) { + objc_setAssociatedObject(link, RRBypassKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +/// Apply the current override to all tracked display links. +void RRApplyOverrideToTrackedLinks(void) { + if (!_rr_lock) return; + + [_rr_lock lock]; + + // Compact nil refs + [_rr_trackedLinks compact]; + + NSUInteger count = [_rr_trackedLinks count]; + for (NSUInteger i = 0; i < count; i++) { + CADisplayLink *link = [_rr_trackedLinks pointerAtIndex:i]; + if (!link) continue; + + // Skip bypassed links + if (objc_getAssociatedObject(link, RRBypassKey)) continue; + + if (@available(iOS 15.0, *)) { + // Read stored original range + NSDictionary *stored = objc_getAssociatedObject(link, RROriginalRangeKey); + + float cap = _rr_overrideMaxRate; + + if (cap > 0 && stored) { + float origMin = [stored[@"min"] floatValue]; + float origMax = [stored[@"max"] floatValue]; + float origPref = [stored[@"preferred"] floatValue]; + CAFrameRateRange capped = CAFrameRateRangeMake( + fminf(origMin, cap), + fminf(origMax, cap), + fminf(origPref, cap) + ); + // Use original IMP directly to avoid re-triggering our swizzle + // (which would overwrite the stored original with the capped value) + SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); + if (_rr_origSetRange) { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, capped); + } + } else if (cap <= 0 && stored) { + // Restore original + float origMin = [stored[@"min"] floatValue]; + float origMax = [stored[@"max"] floatValue]; + float origPref = [stored[@"preferred"] floatValue]; + SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); + if (_rr_origSetRange) { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, CAFrameRateRangeMake(origMin, origMax, origPref)); + } + } + } + } + + [_rr_lock unlock]; +} + +#pragma mark - Swizzled implementations + +/// Swizzled addToRunLoop:forMode: — tracks every display link. +static void rr_addToRunLoop(CADisplayLink *self, SEL _cmd, NSRunLoop *runloop, NSRunLoopMode mode) { + // Track this link + [_rr_lock lock]; + if (_rr_trackedLinks) { + // Check not already tracked + BOOL found = NO; + [_rr_trackedLinks compact]; + for (NSUInteger i = 0; i < [_rr_trackedLinks count]; i++) { + if ([_rr_trackedLinks pointerAtIndex:i] == (__bridge void *)self) { + found = YES; + break; + } + } + if (!found) { + [_rr_trackedLinks addPointer:(__bridge void *)self]; + } + } + [_rr_lock unlock]; + + // Call original — the IMP was saved during swizzle + ((void (*)(id, SEL, NSRunLoop *, NSRunLoopMode))_rr_origAddToRunLoop)(self, _cmd, runloop, mode); +} + +/// Swizzled setPreferredFrameRateRange: — stores original range and applies cap. +static void rr_setPreferredFrameRateRange(CADisplayLink *self, SEL _cmd, CAFrameRateRange range) { + // Bypass tagged links + if (objc_getAssociatedObject(self, RRBypassKey)) { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); + return; + } + + // Store original range + if (@available(iOS 15.0, *)) { + NSDictionary *stored = @{ + @"min": @(range.minimum), + @"max": @(range.maximum), + @"preferred": @(range.preferred), + }; + objc_setAssociatedObject(self, RROriginalRangeKey, stored, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + // Apply cap if active + float cap = _rr_overrideMaxRate; + if (cap > 0) { + if (@available(iOS 15.0, *)) { + CAFrameRateRange capped = CAFrameRateRangeMake( + fminf(range.minimum, cap), + fminf(range.maximum, cap), + fminf(range.preferred, cap) + ); + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, capped); + } else { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); + } + } else { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); + } +} + +#pragma mark - +load (runs before main, before Flutter engine starts) + +@interface RRDisplayLinkSwizzle : NSObject +@end + +@implementation RRDisplayLinkSwizzle + ++ (void)load { + _rr_trackedLinks = [NSPointerArray weakObjectsPointerArray]; + _rr_lock = [[NSLock alloc] init]; + + Class cls = [CADisplayLink class]; + + // Swizzle addToRunLoop:forMode: + { + SEL sel = @selector(addToRunLoop:forMode:); + Method method = class_getInstanceMethod(cls, sel); + if (method) { + _rr_origAddToRunLoop = method_getImplementation(method); + method_setImplementation(method, (IMP)rr_addToRunLoop); + } + } + + // Swizzle setPreferredFrameRateRange: (iOS 15+) + if (@available(iOS 15.0, *)) { + SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); + Method method = class_getInstanceMethod(cls, sel); + if (method) { + _rr_origSetRange = method_getImplementation(method); + method_setImplementation(method, (IMP)rr_setPreferredFrameRateRange); + } + } +} + +@end diff --git a/third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift b/third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift new file mode 100644 index 00000000..1e0d806c --- /dev/null +++ b/third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift @@ -0,0 +1,210 @@ +import Flutter +import UIKit +import QuartzCore + +public class RefreshRatePlugin: NSObject, FlutterPlugin, RefreshRateHostApi { + + private var flutterApi: RefreshRateFlutterApi? + private var displayLink: CADisplayLink? + private var boostDisplayLink: CADisplayLink? + private var lastReportedRate: Double = 0 + private var powerObserver: NSObjectProtocol? + private var thermalObserver: NSObjectProtocol? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = RefreshRatePlugin() + RefreshRateHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) + instance.flutterApi = RefreshRateFlutterApi(binaryMessenger: registrar.messenger()) + instance.startMonitoring() + } + + // MARK: - RefreshRateHostApi + + func getDisplayInfo() throws -> DisplayInfoMessage { + let maxRate = getMaxRefreshRate() + let currentRate = getCurrentRefreshRate() + let proMotion = isProMotionPlistKeySet() + let isLow = ProcessInfo.processInfo.isLowPowerModeEnabled + let thermal = thermalIndex() + + return DisplayInfoMessage( + currentRate: currentRate, + maxRate: maxRate, + minRate: 60.0, + supportedRates: getSupportedRefreshRates(), + isVariableRefreshRate: maxRate > 60, + engineTargetRate: currentRate, + iosProMotionEnabled: proMotion, + androidApiLevel: nil, + isLowPowerMode: isLow, + thermalStateIndex: thermal, + hasAdaptiveRefreshRate: maxRate > 60, + displayServer: nil, + monitorCount: nil + ) + } + + func enable() throws { try setToMax(forceHighest: false) } + func disable() throws { resetCap() } + func preferMax() throws { try setToMax(forceHighest: false) } + func preferDefault() throws { resetCap() } + + func matchContent(fps: Double) throws { + guard #available(iOS 15.0, *) else { return } + let maxRate = getMaxRefreshRate() + let multiple = max(1.0, (maxRate / fps).rounded(.down)) + let preferredMax = fps * multiple + RRSetOverrideMaxRate(Float(fps)) + RRApplyOverrideToTrackedLinks() + setupBoostDisplayLink(min: fps, max: preferredMax, preferred: preferredMax) + } + + func boost(durationMs: Int64) throws { + guard #available(iOS 15.0, *) else { return } + let maxRate = getMaxRefreshRate() + RRSetOverrideMaxRate(0) + RRApplyOverrideToTrackedLinks() + setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) + DispatchQueue.main.asyncAfter(deadline: .now() + Double(durationMs) / 1000.0) { + self.removeBoostDisplayLink() + RRSetOverrideMaxRate(0) + RRApplyOverrideToTrackedLinks() + } + } + + func setCategory(categoryIndex: Int64) throws { + switch categoryIndex { + case 3: try? enable() + case 0, 1: try? disable() + default: break + } + } + + func setTouchBoost(enabled: Bool) throws { + // No iOS equivalent; no-op + } + + func isSupported() throws -> Bool { + if #available(iOS 15.0, *) { return getMaxRefreshRate() > 60 } + return false + } + + // MARK: - Private control + + private func setToMax(forceHighest: Bool) throws { + let maxRate = getMaxRefreshRate() + let isPad = UIDevice.current.userInterfaceIdiom == .pad + let unlocked = isProMotionPlistKeySet() || isPad + if !unlocked { logPlistWarning(maxRate: maxRate) } + guard #available(iOS 15.0, *) else { return } + RRSetOverrideMaxRate(0) + RRApplyOverrideToTrackedLinks() + if forceHighest { + setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) + } else { + removeBoostDisplayLink() + } + } + + private func resetCap() { + RRSetOverrideMaxRate(60.0) + RRApplyOverrideToTrackedLinks() + removeBoostDisplayLink() + } + + // MARK: - Boost display link + + @available(iOS 15.0, *) + private func setupBoostDisplayLink(min: Double, max: Double, preferred: Double) { + removeBoostDisplayLink() + let link = CADisplayLink(target: self, selector: #selector(boostFired)) + RRBypassDisplayLink(link) + link.preferredFrameRateRange = CAFrameRateRange( + minimum: Float(min), maximum: Float(max), preferred: Float(preferred)) + link.add(to: .main, forMode: .common) + boostDisplayLink = link + } + + private func removeBoostDisplayLink() { + boostDisplayLink?.invalidate() + boostDisplayLink = nil + } + + @objc private func boostFired(_ link: CADisplayLink) {} + + // MARK: - Monitoring + + @objc private func monitorLinkFired(_ link: CADisplayLink) { + let rate = link.duration > 0 ? 1.0 / link.duration : 60.0 + if abs(rate - lastReportedRate) > 5.0 { + lastReportedRate = rate + let info = (try? getDisplayInfo()) ?? DisplayInfoMessage( + currentRate: rate, maxRate: rate, minRate: 60.0, + supportedRates: [60.0, rate], isVariableRefreshRate: rate > 60, + engineTargetRate: rate, iosProMotionEnabled: nil, + androidApiLevel: nil, isLowPowerMode: nil, + thermalStateIndex: nil, hasAdaptiveRefreshRate: nil, + displayServer: nil, monitorCount: nil) + flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + } + + private func startMonitoring() { + if displayLink == nil { + let link = CADisplayLink(target: self, selector: #selector(monitorLinkFired)) + RRBypassDisplayLink(link) + link.add(to: .main, forMode: .common) + displayLink = link + } + powerObserver = NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main) { [weak self] _ in + guard let info = try? self?.getDisplayInfo() else { return } + self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + thermalObserver = NotificationCenter.default.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, object: nil, queue: .main) { [weak self] _ in + guard let info = try? self?.getDisplayInfo() else { return } + self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + } + + // MARK: - Helpers + + private func getMaxRefreshRate() -> Double { Double(UIScreen.main.maximumFramesPerSecond) } + + private func getCurrentRefreshRate() -> Double { + if let link = displayLink, link.duration > 0 { return 1.0 / link.duration } + let max = getMaxRefreshRate() + let isPad = UIDevice.current.userInterfaceIdiom == .pad + return (max > 60 && (isProMotionPlistKeySet() || isPad)) ? max : 60.0 + } + + private func getSupportedRefreshRates() -> [Double] { + let max = getMaxRefreshRate() + return max > 60 ? [60.0, max] : [60.0] + } + + private func isProMotionPlistKeySet() -> Bool { + return Bundle.main.object(forInfoDictionaryKey: "CADisableMinimumFrameDurationOnPhone") as? Bool ?? false + } + + private func thermalIndex() -> Int64? { + switch ProcessInfo.processInfo.thermalState { + case .nominal: return 0 + case .fair: return 1 + case .serious: return 2 + case .critical: return 3 + @unknown default: return nil + } + } + + private func logPlistWarning(maxRate: Double) { + print(""" + ⚠️ [refresh_rate] CADisableMinimumFrameDurationOnPhone not set in Info.plist! + App is locked to 60Hz on this \(maxRate)Hz device. + Add to ios/Runner/Info.plist: + CADisableMinimumFrameDurationOnPhone + + """) + } +} diff --git a/third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift b/third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift new file mode 100644 index 00000000..70c230c8 --- /dev/null +++ b/third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift @@ -0,0 +1,369 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Generated class from Pigeon that represents data sent in messages. +struct DisplayInfoMessage { + var currentRate: Double? = nil + var maxRate: Double? = nil + var minRate: Double? = nil + var supportedRates: [Double?]? = nil + var isVariableRefreshRate: Bool? = nil + var engineTargetRate: Double? = nil + var iosProMotionEnabled: Bool? = nil + var androidApiLevel: Int64? = nil + var isLowPowerMode: Bool? = nil + var thermalStateIndex: Int64? = nil + var hasAdaptiveRefreshRate: Bool? = nil + var displayServer: String? = nil + var monitorCount: Int64? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> DisplayInfoMessage? { + let currentRate: Double? = nilOrValue(pigeonVar_list[0]) + let maxRate: Double? = nilOrValue(pigeonVar_list[1]) + let minRate: Double? = nilOrValue(pigeonVar_list[2]) + let supportedRates: [Double?]? = nilOrValue(pigeonVar_list[3]) + let isVariableRefreshRate: Bool? = nilOrValue(pigeonVar_list[4]) + let engineTargetRate: Double? = nilOrValue(pigeonVar_list[5]) + let iosProMotionEnabled: Bool? = nilOrValue(pigeonVar_list[6]) + let androidApiLevel: Int64? = nilOrValue(pigeonVar_list[7]) + let isLowPowerMode: Bool? = nilOrValue(pigeonVar_list[8]) + let thermalStateIndex: Int64? = nilOrValue(pigeonVar_list[9]) + let hasAdaptiveRefreshRate: Bool? = nilOrValue(pigeonVar_list[10]) + let displayServer: String? = nilOrValue(pigeonVar_list[11]) + let monitorCount: Int64? = nilOrValue(pigeonVar_list[12]) + + return DisplayInfoMessage( + currentRate: currentRate, + maxRate: maxRate, + minRate: minRate, + supportedRates: supportedRates, + isVariableRefreshRate: isVariableRefreshRate, + engineTargetRate: engineTargetRate, + iosProMotionEnabled: iosProMotionEnabled, + androidApiLevel: androidApiLevel, + isLowPowerMode: isLowPowerMode, + thermalStateIndex: thermalStateIndex, + hasAdaptiveRefreshRate: hasAdaptiveRefreshRate, + displayServer: displayServer, + monitorCount: monitorCount + ) + } + func toList() -> [Any?] { + return [ + currentRate, + maxRate, + minRate, + supportedRates, + isVariableRefreshRate, + engineTargetRate, + iosProMotionEnabled, + androidApiLevel, + isLowPowerMode, + thermalStateIndex, + hasAdaptiveRefreshRate, + displayServer, + monitorCount, + ] + } +} + +private class RefreshRateApiPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return DisplayInfoMessage.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class RefreshRateApiPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? DisplayInfoMessage { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class RefreshRateApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return RefreshRateApiPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return RefreshRateApiPigeonCodecWriter(data: data) + } +} + +class RefreshRateApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = RefreshRateApiPigeonCodec(readerWriter: RefreshRateApiPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol RefreshRateHostApi { + func getDisplayInfo() throws -> DisplayInfoMessage + func enable() throws + func disable() throws + func preferMax() throws + func preferDefault() throws + func matchContent(fps: Double) throws + func boost(durationMs: Int64) throws + func setCategory(categoryIndex: Int64) throws + func setTouchBoost(enabled: Bool) throws + func isSupported() throws -> Bool +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class RefreshRateHostApiSetup { + static var codec: FlutterStandardMessageCodec { RefreshRateApiPigeonCodec.shared } + /// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let getDisplayInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getDisplayInfoChannel.setMessageHandler { _, reply in + do { + let result = try api.getDisplayInfo() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getDisplayInfoChannel.setMessageHandler(nil) + } + let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableChannel.setMessageHandler { _, reply in + do { + try api.enable() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableChannel.setMessageHandler(nil) + } + let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disableChannel.setMessageHandler { _, reply in + do { + try api.disable() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + disableChannel.setMessageHandler(nil) + } + let preferMaxChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + preferMaxChannel.setMessageHandler { _, reply in + do { + try api.preferMax() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + preferMaxChannel.setMessageHandler(nil) + } + let preferDefaultChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + preferDefaultChannel.setMessageHandler { _, reply in + do { + try api.preferDefault() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + preferDefaultChannel.setMessageHandler(nil) + } + let matchContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + matchContentChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let fpsArg = args[0] as! Double + do { + try api.matchContent(fps: fpsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + matchContentChannel.setMessageHandler(nil) + } + let boostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + boostChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let durationMsArg = args[0] as! Int64 + do { + try api.boost(durationMs: durationMsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + boostChannel.setMessageHandler(nil) + } + let setCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setCategoryChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let categoryIndexArg = args[0] as! Int64 + do { + try api.setCategory(categoryIndex: categoryIndexArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setCategoryChannel.setMessageHandler(nil) + } + let setTouchBoostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setTouchBoostChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let enabledArg = args[0] as! Bool + do { + try api.setTouchBoost(enabled: enabledArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setTouchBoostChannel.setMessageHandler(nil) + } + let isSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isSupportedChannel.setMessageHandler { _, reply in + do { + let result = try api.isSupported() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + isSupportedChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol RefreshRateFlutterApiProtocol { + func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) +} +class RefreshRateFlutterApi: RefreshRateFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: RefreshRateApiPigeonCodec { + return RefreshRateApiPigeonCodec.shared + } + func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([infoArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(Void())) + } + } + } +} diff --git a/third_party/refresh_rate/ios/refresh_rate.podspec b/third_party/refresh_rate/ios/refresh_rate.podspec new file mode 100644 index 00000000..a6912cfb --- /dev/null +++ b/third_party/refresh_rate/ios/refresh_rate.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'refresh_rate' + s.version = '1.0.2' + s.summary = 'Control display refresh rates in Flutter.' + s.description = <<-DESC +Cross-platform Flutter plugin to query and control display refresh rates. +Unlock high refresh rates (90Hz/120Hz/144Hz) by properly communicating +with the OS compositor — something Flutter doesn't do by default. + DESC + s.homepage = 'https://qoder.in' + s.license = { :file => '../LICENSE' } + s.author = { 'Qoder' => 'dev@qoder.in' } + s.source = { :path => '.' } + s.source_files = [ + 'refresh_rate/Sources/refresh_rate/**/*.swift', + 'refresh_rate/Sources/refresh_rate_objc/**/*.{h,m}' + ] + s.public_header_files = 'refresh_rate/Sources/refresh_rate_objc/include/**/*.h' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + s.swift_version = '5.0' +end diff --git a/third_party/refresh_rate/ios/refresh_rate/Package.swift b/third_party/refresh_rate/ios/refresh_rate/Package.swift new file mode 100644 index 00000000..08b8c9ab --- /dev/null +++ b/third_party/refresh_rate/ios/refresh_rate/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// +import PackageDescription + +let package = Package( + name: "refresh_rate", + platforms: [ + .iOS(.v12), + ], + products: [ + .library(name: "refresh-rate", targets: ["refresh_rate"]), + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + ], + targets: [ + .target( + name: "refresh_rate_objc", + path: "Sources/refresh_rate_objc", + publicHeadersPath: "include" + ), + .target( + name: "refresh_rate", + dependencies: [ + "refresh_rate_objc", + .product(name: "FlutterFramework", package: "FlutterFramework"), + ], + path: "Sources/refresh_rate" + ), + ] +) diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift new file mode 100644 index 00000000..6845df98 --- /dev/null +++ b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift @@ -0,0 +1,214 @@ +import Flutter +import UIKit +import QuartzCore + +#if SWIFT_PACKAGE +import refresh_rate_objc +#endif + +public class RefreshRatePlugin: NSObject, FlutterPlugin, RefreshRateHostApi { + + private var flutterApi: RefreshRateFlutterApi? + private var displayLink: CADisplayLink? + private var boostDisplayLink: CADisplayLink? + private var lastReportedRate: Double = 0 + private var powerObserver: NSObjectProtocol? + private var thermalObserver: NSObjectProtocol? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = RefreshRatePlugin() + RefreshRateHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) + instance.flutterApi = RefreshRateFlutterApi(binaryMessenger: registrar.messenger()) + instance.startMonitoring() + } + + // MARK: - RefreshRateHostApi + + func getDisplayInfo() throws -> DisplayInfoMessage { + let maxRate = getMaxRefreshRate() + let currentRate = getCurrentRefreshRate() + let proMotion = isProMotionPlistKeySet() + let isLow = ProcessInfo.processInfo.isLowPowerModeEnabled + let thermal = thermalIndex() + + return DisplayInfoMessage( + currentRate: currentRate, + maxRate: maxRate, + minRate: 60.0, + supportedRates: getSupportedRefreshRates(), + isVariableRefreshRate: maxRate > 60, + engineTargetRate: currentRate, + iosProMotionEnabled: proMotion, + androidApiLevel: nil, + isLowPowerMode: isLow, + thermalStateIndex: thermal, + hasAdaptiveRefreshRate: maxRate > 60, + displayServer: nil, + monitorCount: nil + ) + } + + func enable() throws { try setToMax(forceHighest: false) } + func disable() throws { resetCap() } + func preferMax() throws { try setToMax(forceHighest: false) } + func preferDefault() throws { resetCap() } + + func matchContent(fps: Double) throws { + guard #available(iOS 15.0, *) else { return } + let maxRate = getMaxRefreshRate() + let multiple = max(1.0, (maxRate / fps).rounded(.down)) + let preferredMax = fps * multiple + RRSetOverrideMaxRate(Float(fps)) + RRApplyOverrideToTrackedLinks() + setupBoostDisplayLink(min: fps, max: preferredMax, preferred: preferredMax) + } + + func boost(durationMs: Int64) throws { + guard #available(iOS 15.0, *) else { return } + let maxRate = getMaxRefreshRate() + RRSetOverrideMaxRate(0) + RRApplyOverrideToTrackedLinks() + setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) + DispatchQueue.main.asyncAfter(deadline: .now() + Double(durationMs) / 1000.0) { + self.removeBoostDisplayLink() + RRSetOverrideMaxRate(0) + RRApplyOverrideToTrackedLinks() + } + } + + func setCategory(categoryIndex: Int64) throws { + switch categoryIndex { + case 3: try? enable() + case 0, 1: try? disable() + default: break + } + } + + func setTouchBoost(enabled: Bool) throws { + // No iOS equivalent; no-op + } + + func isSupported() throws -> Bool { + if #available(iOS 15.0, *) { return getMaxRefreshRate() > 60 } + return false + } + + // MARK: - Private control + + private func setToMax(forceHighest: Bool) throws { + let maxRate = getMaxRefreshRate() + let isPad = UIDevice.current.userInterfaceIdiom == .pad + let unlocked = isProMotionPlistKeySet() || isPad + if !unlocked { logPlistWarning(maxRate: maxRate) } + guard #available(iOS 15.0, *) else { return } + RRSetOverrideMaxRate(0) + RRApplyOverrideToTrackedLinks() + if forceHighest { + setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) + } else { + removeBoostDisplayLink() + } + } + + private func resetCap() { + RRSetOverrideMaxRate(60.0) + RRApplyOverrideToTrackedLinks() + removeBoostDisplayLink() + } + + // MARK: - Boost display link + + @available(iOS 15.0, *) + private func setupBoostDisplayLink(min: Double, max: Double, preferred: Double) { + removeBoostDisplayLink() + let link = CADisplayLink(target: self, selector: #selector(boostFired)) + RRBypassDisplayLink(link) + link.preferredFrameRateRange = CAFrameRateRange( + minimum: Float(min), maximum: Float(max), preferred: Float(preferred)) + link.add(to: .main, forMode: .common) + boostDisplayLink = link + } + + private func removeBoostDisplayLink() { + boostDisplayLink?.invalidate() + boostDisplayLink = nil + } + + @objc private func boostFired(_ link: CADisplayLink) {} + + // MARK: - Monitoring + + @objc private func monitorLinkFired(_ link: CADisplayLink) { + let rate = link.duration > 0 ? 1.0 / link.duration : 60.0 + if abs(rate - lastReportedRate) > 5.0 { + lastReportedRate = rate + let info = (try? getDisplayInfo()) ?? DisplayInfoMessage( + currentRate: rate, maxRate: rate, minRate: 60.0, + supportedRates: [60.0, rate], isVariableRefreshRate: rate > 60, + engineTargetRate: rate, iosProMotionEnabled: nil, + androidApiLevel: nil, isLowPowerMode: nil, + thermalStateIndex: nil, hasAdaptiveRefreshRate: nil, + displayServer: nil, monitorCount: nil) + flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + } + + private func startMonitoring() { + if displayLink == nil { + let link = CADisplayLink(target: self, selector: #selector(monitorLinkFired)) + RRBypassDisplayLink(link) + link.add(to: .main, forMode: .common) + displayLink = link + } + powerObserver = NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main) { [weak self] _ in + guard let info = try? self?.getDisplayInfo() else { return } + self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + thermalObserver = NotificationCenter.default.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, object: nil, queue: .main) { [weak self] _ in + guard let info = try? self?.getDisplayInfo() else { return } + self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + } + + // MARK: - Helpers + + private func getMaxRefreshRate() -> Double { Double(UIScreen.main.maximumFramesPerSecond) } + + private func getCurrentRefreshRate() -> Double { + if let link = displayLink, link.duration > 0 { return 1.0 / link.duration } + let max = getMaxRefreshRate() + let isPad = UIDevice.current.userInterfaceIdiom == .pad + return (max > 60 && (isProMotionPlistKeySet() || isPad)) ? max : 60.0 + } + + private func getSupportedRefreshRates() -> [Double] { + let max = getMaxRefreshRate() + return max > 60 ? [60.0, max] : [60.0] + } + + private func isProMotionPlistKeySet() -> Bool { + return Bundle.main.object(forInfoDictionaryKey: "CADisableMinimumFrameDurationOnPhone") as? Bool ?? false + } + + private func thermalIndex() -> Int64? { + switch ProcessInfo.processInfo.thermalState { + case .nominal: return 0 + case .fair: return 1 + case .serious: return 2 + case .critical: return 3 + @unknown default: return nil + } + } + + private func logPlistWarning(maxRate: Double) { + print(""" + ⚠️ [refresh_rate] CADisableMinimumFrameDurationOnPhone not set in Info.plist! + App is locked to 60Hz on this \(maxRate)Hz device. + Add to ios/Runner/Info.plist: + CADisableMinimumFrameDurationOnPhone + + """) + } +} diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift new file mode 100644 index 00000000..70c230c8 --- /dev/null +++ b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift @@ -0,0 +1,369 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Generated class from Pigeon that represents data sent in messages. +struct DisplayInfoMessage { + var currentRate: Double? = nil + var maxRate: Double? = nil + var minRate: Double? = nil + var supportedRates: [Double?]? = nil + var isVariableRefreshRate: Bool? = nil + var engineTargetRate: Double? = nil + var iosProMotionEnabled: Bool? = nil + var androidApiLevel: Int64? = nil + var isLowPowerMode: Bool? = nil + var thermalStateIndex: Int64? = nil + var hasAdaptiveRefreshRate: Bool? = nil + var displayServer: String? = nil + var monitorCount: Int64? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> DisplayInfoMessage? { + let currentRate: Double? = nilOrValue(pigeonVar_list[0]) + let maxRate: Double? = nilOrValue(pigeonVar_list[1]) + let minRate: Double? = nilOrValue(pigeonVar_list[2]) + let supportedRates: [Double?]? = nilOrValue(pigeonVar_list[3]) + let isVariableRefreshRate: Bool? = nilOrValue(pigeonVar_list[4]) + let engineTargetRate: Double? = nilOrValue(pigeonVar_list[5]) + let iosProMotionEnabled: Bool? = nilOrValue(pigeonVar_list[6]) + let androidApiLevel: Int64? = nilOrValue(pigeonVar_list[7]) + let isLowPowerMode: Bool? = nilOrValue(pigeonVar_list[8]) + let thermalStateIndex: Int64? = nilOrValue(pigeonVar_list[9]) + let hasAdaptiveRefreshRate: Bool? = nilOrValue(pigeonVar_list[10]) + let displayServer: String? = nilOrValue(pigeonVar_list[11]) + let monitorCount: Int64? = nilOrValue(pigeonVar_list[12]) + + return DisplayInfoMessage( + currentRate: currentRate, + maxRate: maxRate, + minRate: minRate, + supportedRates: supportedRates, + isVariableRefreshRate: isVariableRefreshRate, + engineTargetRate: engineTargetRate, + iosProMotionEnabled: iosProMotionEnabled, + androidApiLevel: androidApiLevel, + isLowPowerMode: isLowPowerMode, + thermalStateIndex: thermalStateIndex, + hasAdaptiveRefreshRate: hasAdaptiveRefreshRate, + displayServer: displayServer, + monitorCount: monitorCount + ) + } + func toList() -> [Any?] { + return [ + currentRate, + maxRate, + minRate, + supportedRates, + isVariableRefreshRate, + engineTargetRate, + iosProMotionEnabled, + androidApiLevel, + isLowPowerMode, + thermalStateIndex, + hasAdaptiveRefreshRate, + displayServer, + monitorCount, + ] + } +} + +private class RefreshRateApiPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return DisplayInfoMessage.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class RefreshRateApiPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? DisplayInfoMessage { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class RefreshRateApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return RefreshRateApiPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return RefreshRateApiPigeonCodecWriter(data: data) + } +} + +class RefreshRateApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = RefreshRateApiPigeonCodec(readerWriter: RefreshRateApiPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol RefreshRateHostApi { + func getDisplayInfo() throws -> DisplayInfoMessage + func enable() throws + func disable() throws + func preferMax() throws + func preferDefault() throws + func matchContent(fps: Double) throws + func boost(durationMs: Int64) throws + func setCategory(categoryIndex: Int64) throws + func setTouchBoost(enabled: Bool) throws + func isSupported() throws -> Bool +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class RefreshRateHostApiSetup { + static var codec: FlutterStandardMessageCodec { RefreshRateApiPigeonCodec.shared } + /// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let getDisplayInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getDisplayInfoChannel.setMessageHandler { _, reply in + do { + let result = try api.getDisplayInfo() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getDisplayInfoChannel.setMessageHandler(nil) + } + let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableChannel.setMessageHandler { _, reply in + do { + try api.enable() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableChannel.setMessageHandler(nil) + } + let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disableChannel.setMessageHandler { _, reply in + do { + try api.disable() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + disableChannel.setMessageHandler(nil) + } + let preferMaxChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + preferMaxChannel.setMessageHandler { _, reply in + do { + try api.preferMax() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + preferMaxChannel.setMessageHandler(nil) + } + let preferDefaultChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + preferDefaultChannel.setMessageHandler { _, reply in + do { + try api.preferDefault() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + preferDefaultChannel.setMessageHandler(nil) + } + let matchContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + matchContentChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let fpsArg = args[0] as! Double + do { + try api.matchContent(fps: fpsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + matchContentChannel.setMessageHandler(nil) + } + let boostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + boostChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let durationMsArg = args[0] as! Int64 + do { + try api.boost(durationMs: durationMsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + boostChannel.setMessageHandler(nil) + } + let setCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setCategoryChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let categoryIndexArg = args[0] as! Int64 + do { + try api.setCategory(categoryIndex: categoryIndexArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setCategoryChannel.setMessageHandler(nil) + } + let setTouchBoostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setTouchBoostChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let enabledArg = args[0] as! Bool + do { + try api.setTouchBoost(enabled: enabledArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setTouchBoostChannel.setMessageHandler(nil) + } + let isSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isSupportedChannel.setMessageHandler { _, reply in + do { + let result = try api.isSupported() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + isSupportedChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol RefreshRateFlutterApiProtocol { + func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) +} +class RefreshRateFlutterApi: RefreshRateFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: RefreshRateApiPigeonCodec { + return RefreshRateApiPigeonCodec.shared + } + func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([infoArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(Void())) + } + } + } +} diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m new file mode 100644 index 00000000..2e40973b --- /dev/null +++ b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m @@ -0,0 +1,188 @@ +#import "DisplayLinkSwizzle.h" +#import + +// Key for tagging display links that should bypass the override. +static const void *RRBypassKey = &RRBypassKey; +// Key for storing the original (uncapped) frame rate range. +static const void *RROriginalRangeKey = &RROriginalRangeKey; + +/// Global override cap. 0 means no override (pass through). +static float _rr_overrideMaxRate = 0; + +/// Storage for tracked display links (weak-ish — we check validity). +static NSPointerArray *_rr_trackedLinks = nil; +static NSLock *_rr_lock = nil; + +// Store original IMPs — declared early so swizzled functions can reference them. +static IMP _rr_origAddToRunLoop = NULL; +static IMP _rr_origSetRange = NULL; + +#pragma mark - Public C interface (called from Swift) + +/// Set the max frame rate cap. Pass 0 to disable. +void RRSetOverrideMaxRate(float rate) { + _rr_overrideMaxRate = rate; +} + +float RRGetOverrideMaxRate(void) { + return _rr_overrideMaxRate; +} + +/// Tag a display link so the swizzle bypasses it. +void RRBypassDisplayLink(CADisplayLink *link) { + objc_setAssociatedObject(link, RRBypassKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +/// Apply the current override to all tracked display links. +void RRApplyOverrideToTrackedLinks(void) { + if (!_rr_lock) return; + + [_rr_lock lock]; + + // Compact nil refs + [_rr_trackedLinks compact]; + + NSUInteger count = [_rr_trackedLinks count]; + for (NSUInteger i = 0; i < count; i++) { + CADisplayLink *link = [_rr_trackedLinks pointerAtIndex:i]; + if (!link) continue; + + // Skip bypassed links + if (objc_getAssociatedObject(link, RRBypassKey)) continue; + + if (@available(iOS 15.0, *)) { + // Read stored original range + NSDictionary *stored = objc_getAssociatedObject(link, RROriginalRangeKey); + + float cap = _rr_overrideMaxRate; + + if (cap > 0 && stored) { + float origMin = [stored[@"min"] floatValue]; + float origMax = [stored[@"max"] floatValue]; + float origPref = [stored[@"preferred"] floatValue]; + CAFrameRateRange capped = CAFrameRateRangeMake( + fminf(origMin, cap), + fminf(origMax, cap), + fminf(origPref, cap) + ); + // Use original IMP directly to avoid re-triggering our swizzle + // (which would overwrite the stored original with the capped value) + SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); + if (_rr_origSetRange) { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, capped); + } + } else if (cap <= 0 && stored) { + // Restore original + float origMin = [stored[@"min"] floatValue]; + float origMax = [stored[@"max"] floatValue]; + float origPref = [stored[@"preferred"] floatValue]; + SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); + if (_rr_origSetRange) { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, CAFrameRateRangeMake(origMin, origMax, origPref)); + } + } + } + } + + [_rr_lock unlock]; +} + +#pragma mark - Swizzled implementations + +/// Swizzled addToRunLoop:forMode: — tracks every display link. +static void rr_addToRunLoop(CADisplayLink *self, SEL _cmd, NSRunLoop *runloop, NSRunLoopMode mode) { + // Track this link + [_rr_lock lock]; + if (_rr_trackedLinks) { + // Check not already tracked + BOOL found = NO; + [_rr_trackedLinks compact]; + for (NSUInteger i = 0; i < [_rr_trackedLinks count]; i++) { + if ([_rr_trackedLinks pointerAtIndex:i] == (__bridge void *)self) { + found = YES; + break; + } + } + if (!found) { + [_rr_trackedLinks addPointer:(__bridge void *)self]; + } + } + [_rr_lock unlock]; + + // Call original — the IMP was saved during swizzle + ((void (*)(id, SEL, NSRunLoop *, NSRunLoopMode))_rr_origAddToRunLoop)(self, _cmd, runloop, mode); +} + +/// Swizzled setPreferredFrameRateRange: — stores original range and applies cap. +static void rr_setPreferredFrameRateRange(CADisplayLink *self, SEL _cmd, CAFrameRateRange range) API_AVAILABLE(ios(15.0)); + +static void rr_setPreferredFrameRateRange(CADisplayLink *self, SEL _cmd, CAFrameRateRange range) { + // Bypass tagged links + if (objc_getAssociatedObject(self, RRBypassKey)) { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); + return; + } + + // Store original range + if (@available(iOS 15.0, *)) { + NSDictionary *stored = @{ + @"min": @(range.minimum), + @"max": @(range.maximum), + @"preferred": @(range.preferred), + }; + objc_setAssociatedObject(self, RROriginalRangeKey, stored, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + // Apply cap if active + float cap = _rr_overrideMaxRate; + if (cap > 0) { + if (@available(iOS 15.0, *)) { + CAFrameRateRange capped = CAFrameRateRangeMake( + fminf(range.minimum, cap), + fminf(range.maximum, cap), + fminf(range.preferred, cap) + ); + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, capped); + } else { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); + } + } else { + ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); + } +} + +#pragma mark - +load (runs before main, before Flutter engine starts) + +@interface RRDisplayLinkSwizzle : NSObject +@end + +@implementation RRDisplayLinkSwizzle + ++ (void)load { + _rr_trackedLinks = [NSPointerArray weakObjectsPointerArray]; + _rr_lock = [[NSLock alloc] init]; + + Class cls = [CADisplayLink class]; + + // Swizzle addToRunLoop:forMode: + { + SEL sel = @selector(addToRunLoop:forMode:); + Method method = class_getInstanceMethod(cls, sel); + if (method) { + _rr_origAddToRunLoop = method_getImplementation(method); + method_setImplementation(method, (IMP)rr_addToRunLoop); + } + } + + // Swizzle setPreferredFrameRateRange: (iOS 15+) + if (@available(iOS 15.0, *)) { + SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); + Method method = class_getInstanceMethod(cls, sel); + if (method) { + _rr_origSetRange = method_getImplementation(method); + method_setImplementation(method, (IMP)rr_setPreferredFrameRateRange); + } + } +} + +@end diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h new file mode 100644 index 00000000..c3fcdbdb --- /dev/null +++ b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h @@ -0,0 +1,14 @@ +#import + +/// Set the max frame rate cap for all display links. Pass 0 to disable. +void RRSetOverrideMaxRate(float rate); + +/// Get the current override max rate. 0 means no override. +float RRGetOverrideMaxRate(void); + +/// Tag a display link so the swizzle bypasses it (for monitoring/boost links). +void RRBypassDisplayLink(CADisplayLink * _Nonnull link); + +/// Apply the current override to all tracked (non-bypassed) display links. +/// Call this after changing the override rate. +void RRApplyOverrideToTrackedLinks(void); diff --git a/third_party/refresh_rate/lib/refresh_rate.dart b/third_party/refresh_rate/lib/refresh_rate.dart new file mode 100644 index 00000000..c462fa98 --- /dev/null +++ b/third_party/refresh_rate/lib/refresh_rate.dart @@ -0,0 +1,5 @@ +export 'src/refresh_rate.dart'; +export 'src/models/display_info.dart'; +export 'src/models/enums.dart'; +export 'src/models/session_report.dart'; +export 'src/verification/refresh_rate_session.dart'; diff --git a/third_party/refresh_rate/lib/refresh_rate_web.dart b/third_party/refresh_rate/lib/refresh_rate_web.dart new file mode 100644 index 00000000..4c8e203c --- /dev/null +++ b/third_party/refresh_rate/lib/refresh_rate_web.dart @@ -0,0 +1,27 @@ +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +import 'src/refresh_rate.dart'; +import 'src/web/web_refresh_rate_adapter.dart'; + +/// Web platform implementation of the RefreshRate plugin. +/// +/// Uses `requestAnimationFrame` interval timing to detect the +/// display's current refresh rate (same technique as TestUFO). +/// Control methods are graceful no-ops because browsers own their +/// vsync scheduling and expose no API to change it. +/// +/// ### What works on web +/// +/// | Feature | Status | +/// |:--------|:------:| +/// | Query current Hz | ✓ (via rAF timing) | +/// | FPS overlay / benchmark | ✓ (pure Dart, unchanged) | +/// | Unlock / control rate | ✗ (browsers own vsync) | +/// | Supported rates / max rate | ✗ (not exposed) | +/// | Low Power Mode / thermal | ✗ (no web equivalent) | +class RefreshRateWeb { + /// Registers the web plugin by swapping in [WebRefreshRateApiAdapter]. + static void registerWith(Registrar registrar) { + RefreshRate.registerAdapter(WebRefreshRateApiAdapter()); + } +} diff --git a/third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart b/third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart new file mode 100644 index 00000000..10763859 --- /dev/null +++ b/third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart @@ -0,0 +1,415 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +class DisplayInfoMessage { + DisplayInfoMessage({ + this.currentRate, + this.maxRate, + this.minRate, + this.supportedRates, + this.isVariableRefreshRate, + this.engineTargetRate, + this.iosProMotionEnabled, + this.androidApiLevel, + this.isLowPowerMode, + this.thermalStateIndex, + this.hasAdaptiveRefreshRate, + this.displayServer, + this.monitorCount, + }); + + double? currentRate; + + double? maxRate; + + double? minRate; + + List? supportedRates; + + bool? isVariableRefreshRate; + + double? engineTargetRate; + + bool? iosProMotionEnabled; + + int? androidApiLevel; + + bool? isLowPowerMode; + + int? thermalStateIndex; + + bool? hasAdaptiveRefreshRate; + + String? displayServer; + + int? monitorCount; + + Object encode() { + return [ + currentRate, + maxRate, + minRate, + supportedRates, + isVariableRefreshRate, + engineTargetRate, + iosProMotionEnabled, + androidApiLevel, + isLowPowerMode, + thermalStateIndex, + hasAdaptiveRefreshRate, + displayServer, + monitorCount, + ]; + } + + static DisplayInfoMessage decode(Object result) { + result as List; + return DisplayInfoMessage( + currentRate: result[0] as double?, + maxRate: result[1] as double?, + minRate: result[2] as double?, + supportedRates: (result[3] as List?)?.cast(), + isVariableRefreshRate: result[4] as bool?, + engineTargetRate: result[5] as double?, + iosProMotionEnabled: result[6] as bool?, + androidApiLevel: result[7] as int?, + isLowPowerMode: result[8] as bool?, + thermalStateIndex: result[9] as int?, + hasAdaptiveRefreshRate: result[10] as bool?, + displayServer: result[11] as String?, + monitorCount: result[12] as int?, + ); + } +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is DisplayInfoMessage) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return DisplayInfoMessage.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class RefreshRateHostApi { + /// Constructor for [RefreshRateHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + RefreshRateHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future getDisplayInfo() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as DisplayInfoMessage?)!; + } + } + + Future enable() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future disable() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future preferMax() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future preferDefault() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future matchContent(double fps) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([fps]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future boost(int durationMs) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([durationMs]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future setCategory(int categoryIndex) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([categoryIndex]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future setTouchBoost(bool enabled) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([enabled]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future isSupported() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } +} + +abstract class RefreshRateFlutterApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onDisplayInfoChanged(DisplayInfoMessage info); + + static void setUp(RefreshRateFlutterApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged was null.'); + final List args = (message as List?)!; + final DisplayInfoMessage? arg_info = (args[0] as DisplayInfoMessage?); + assert(arg_info != null, + 'Argument for dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged was null, expected non-null DisplayInfoMessage.'); + try { + api.onDisplayInfoChanged(arg_info!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/third_party/refresh_rate/lib/src/models/display_info.dart b/third_party/refresh_rate/lib/src/models/display_info.dart new file mode 100644 index 00000000..53b630bb --- /dev/null +++ b/third_party/refresh_rate/lib/src/models/display_info.dart @@ -0,0 +1,128 @@ +import '../generated/refresh_rate_api.g.dart'; +import 'enums.dart'; + +/// A snapshot of the current display configuration and device health. +/// +/// Retrieve a fresh snapshot via [RefreshRate.refresh] or listen to +/// [RefreshRate.onChanged] for real-time updates. +class DisplayInfo { + /// The refresh rate the display is currently running at, in Hz. + final double currentRate; + + /// The maximum refresh rate supported by this display, in Hz. + final double maxRate; + + /// The minimum refresh rate supported by this display, in Hz. + final double minRate; + + /// All refresh rates the display hardware can run at, in Hz. + final List supportedRates; + + /// Whether the display supports variable refresh rate (VRR / LTPO). + final bool isVariableRefreshRate; + + /// The frame rate the Flutter engine is currently targeting, in Hz. + final double engineTargetRate; + + /// Whether iOS ProMotion adaptive refresh is enabled for this app. + /// + /// `null` on non-iOS platforms. + final bool? iosProMotionEnabled; + + /// The Android API level of the device. + /// + /// `null` on non-Android platforms. + final int? androidApiLevel; + + /// Whether the device is in Low Power Mode. + /// + /// `null` when the platform does not expose this information. + final bool? isLowPowerMode; + + /// The current thermal state of the device. + final ThermalState thermalState; + + /// Whether the display supports an adaptive (variable) refresh rate. + /// + /// `null` when the platform does not expose this information. + final bool? hasAdaptiveRefreshRate; + + /// The name of the display server in use (Linux only, e.g. `"wayland"`). + /// + /// `null` on non-Linux platforms. + final String? displayServer; + + /// The number of monitors connected to the device (desktop platforms only). + /// + /// `null` on mobile platforms. + final int? monitorCount; + + /// Creates a new [DisplayInfo] snapshot. + const DisplayInfo({ + required this.currentRate, + required this.maxRate, + required this.minRate, + required this.supportedRates, + required this.isVariableRefreshRate, + required this.engineTargetRate, + this.iosProMotionEnabled, + this.androidApiLevel, + this.isLowPowerMode, + required this.thermalState, + this.hasAdaptiveRefreshRate, + this.displayServer, + this.monitorCount, + }); + + /// Creates a [DisplayInfo] from a platform [DisplayInfoMessage]. + factory DisplayInfo.fromMessage(DisplayInfoMessage msg) { + return DisplayInfo( + currentRate: msg.currentRate ?? 60.0, + maxRate: msg.maxRate ?? 60.0, + minRate: msg.minRate ?? 60.0, + supportedRates: msg.supportedRates?.whereType().toList() ?? const [60.0], + isVariableRefreshRate: msg.isVariableRefreshRate ?? false, + engineTargetRate: msg.engineTargetRate ?? 60.0, + iosProMotionEnabled: msg.iosProMotionEnabled, + androidApiLevel: msg.androidApiLevel, + isLowPowerMode: msg.isLowPowerMode, + thermalState: ThermalState.fromIndex(msg.thermalStateIndex), + hasAdaptiveRefreshRate: msg.hasAdaptiveRefreshRate, + displayServer: msg.displayServer, + monitorCount: msg.monitorCount, + ); + } + + /// A safe fallback [DisplayInfo] used before the first [RefreshRate.refresh] + /// call completes. Assumes a standard 60 Hz non-VRR display. + static const DisplayInfo fallback = DisplayInfo( + currentRate: 60.0, + maxRate: 60.0, + minRate: 60.0, + supportedRates: [60.0], + isVariableRefreshRate: false, + engineTargetRate: 60.0, + thermalState: ThermalState.unknown, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DisplayInfo && + currentRate == other.currentRate && + maxRate == other.maxRate && + minRate == other.minRate && + isVariableRefreshRate == other.isVariableRefreshRate && + engineTargetRate == other.engineTargetRate && + thermalState == other.thermalState && + isLowPowerMode == other.isLowPowerMode; + + @override + int get hashCode => Object.hash(currentRate, maxRate, minRate, + isVariableRefreshRate, engineTargetRate, thermalState, isLowPowerMode); + + @override + String toString() => + 'DisplayInfo(currentRate: ${currentRate}Hz, maxRate: ${maxRate}Hz, ' + 'thermalState: $thermalState, isLowPowerMode: $isLowPowerMode)'; +} diff --git a/third_party/refresh_rate/lib/src/models/enums.dart b/third_party/refresh_rate/lib/src/models/enums.dart new file mode 100644 index 00000000..57900f09 --- /dev/null +++ b/third_party/refresh_rate/lib/src/models/enums.dart @@ -0,0 +1,141 @@ +/// Indicates the desired performance category for the display scheduler. +/// +/// Use [RefreshRate.category] to apply one of these hints to the platform. +enum RateCategory { + /// No refresh-rate hint — the platform decides. + none, + + /// Prefer a low refresh rate (e.g. reading, static UIs). + low, + + /// Prefer the standard refresh rate (default). + normal, + + /// Prefer the highest supported refresh rate + /// (games, animations, scrolling at high speed). + high; + + /// Returns the [RateCategory] matching [index], or [none] if unknown. + static RateCategory fromIndex(int? index) { + switch (index) { + case 0: return none; + case 1: return low; + case 2: return normal; + case 3: return high; + default: return none; + } + } +} + +/// The thermal condition of the device, as reported by the OS. +/// +/// Higher-severity states may cause the OS to throttle the display +/// refresh rate regardless of what the app requests. +enum ThermalState { + /// The device is well within thermal limits — full performance available. + nominal, + + /// The device is slightly warm; minor throttling may apply. + fair, + + /// The device is hot; significant throttling is likely. + serious, + + /// The device is critically hot; the OS may force-reduce refresh rate. + critical, + + /// Thermal state could not be determined (unsupported platform). + unknown; + + /// Returns the [ThermalState] matching [index], or [unknown] if not found. + static ThermalState fromIndex(int? index) { + switch (index) { + case 0: return nominal; + case 1: return fair; + case 2: return serious; + case 3: return critical; + default: return unknown; + } + } +} + +/// Lifecycle state of a [RefreshRateSession] benchmark. +enum SessionState { + /// The session has not yet started collecting data. + idle, + + /// The session is actively collecting frame timings. + running, + + /// The session is in a brief warm-up period after the app resumed. + warmup, + + /// The session was paused due to the app going to the background. + interrupted, + + /// The session has finished and a [SessionReport] is available. + completed, +} + +/// Reason why a time window was excluded from a [SessionReport]. +enum ExclusionReason { + /// App moved to the background. + appBackgrounded, + + /// App became inactive (e.g. notification tray opened). + appInactive, + + /// A brief warm-up window after the app resumed from background. + resumeWarmup, + + /// Overlay opened or the window lost focus. + overlayOrFocusLoss, + + /// The display refresh rate changed mid-session. + displayRateChanged, + + /// Low Power Mode was toggled mid-session. + lowPowerModeChanged, + + /// The thermal state changed mid-session. + thermalStateChanged, +} + +/// Overall quality verdict produced by [SessionReport]. +enum Verdict { + /// Frame rate is consistently at or near the target Hz. + excellent, + + /// Frame rate is mostly stable with minor drops. + good, + + /// Frame rate is acceptable but below optimal. + fair, + + /// Frequent frame drops — the app misses its target regularly. + poor, + + /// Not enough valid data to make a determination. + inconclusive, +} + +/// The most likely rendering bottleneck identified by [SessionReport]. +enum Bottleneck { + /// The UI/build thread is the constraint (slow widget tree rebuilds). + buildBound, + + /// The raster thread is the constraint (complex paint operations). + rasterBound, + + /// The display hardware is capping the achievable frame rate. + displayCapped, + + /// Low Power Mode or battery saver is limiting performance. + powerLimited, + + /// Thermal throttling is reducing the achievable frame rate. + thermalLimited, + + /// No significant bottleneck detected. + none, +} diff --git a/third_party/refresh_rate/lib/src/models/session_report.dart b/third_party/refresh_rate/lib/src/models/session_report.dart new file mode 100644 index 00000000..378a6ca3 --- /dev/null +++ b/third_party/refresh_rate/lib/src/models/session_report.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'enums.dart'; + +/// A point-in-time snapshot of device conditions recorded at session start. +/// +/// Captured when a [RefreshRateSession] is created and embedded in the +/// resulting [SessionReport] so analysis can account for the environment. +class DeviceStateSnapshot { + /// Whether the device was in Low Power Mode when the session started. + /// + /// `null` when the platform does not expose this information. + final bool? isLowPowerMode; + + /// The thermal state of the device when the session started. + final ThermalState thermalState; + + /// Whether the display supports an adaptive (variable) refresh rate. + /// + /// `null` when the platform does not expose this information. + final bool? hasAdaptiveRefreshRate; + + /// The display server in use (Linux only, e.g. `"wayland"`). + /// + /// `null` on non-Linux platforms. + final String? displayServer; + + /// Number of connected monitors (desktop platforms only). + /// + /// `null` on mobile platforms. + final int? monitorCount; + + /// Creates a new [DeviceStateSnapshot]. + const DeviceStateSnapshot({ + this.isLowPowerMode, + required this.thermalState, + this.hasAdaptiveRefreshRate, + this.displayServer, + this.monitorCount, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeviceStateSnapshot && + isLowPowerMode == other.isLowPowerMode && + thermalState == other.thermalState; + + @override + int get hashCode => Object.hash(isLowPowerMode, thermalState); + + /// Serializes this snapshot to a JSON-compatible map. + Map toMap() => { + 'isLowPowerMode': isLowPowerMode, + 'thermalState': thermalState.name, + 'hasAdaptiveRefreshRate': hasAdaptiveRefreshRate, + 'displayServer': displayServer, + 'monitorCount': monitorCount, + }; +} + +/// The result produced when a [RefreshRateSession] ends. +/// +/// Contains FPS statistics, frame timing breakdowns, a performance [verdict], +/// a likely [likelyBottleneck] hint, and excluded-window details. +class SessionReport { + /// The name given to the session when it was started. + final String sessionName; + + /// Overall quality assessment for this session. + final Verdict verdict; + + /// The rendering stage most likely responsible for any frame drops. + final Bottleneck likelyBottleneck; + + /// The target refresh rate in Hz at the time the session began. + final double targetHz; + + /// The average display refresh rate observed during the session, in Hz. + final double observedAvgHz; + + /// The frame duration budget at [targetHz] (1000 / targetHz), in ms. + final double frameBudgetMs; + + /// Average frames per second across all valid frames. + final double avgFps; + + /// The 1st-percentile FPS (worst 1% of frames). + final double onePercentLowFps; + + /// The 5th-percentile FPS (worst 5% of frames). + final double fivePercentLowFps; + + /// Average build (UI thread) time per frame, in ms. + final double avgBuildMs; + + /// Average raster thread time per frame, in ms. + final double avgRasterMs; + + /// Average total frame time (build + raster), in ms. + final double avgTotalFrameMs; + + /// Number of frames that exceeded the frame budget. + final int jankyFrameCount; + + /// Number of frames that exceeded twice the frame budget. + final int severeJankCount; + + /// Percentage of frames that were missed (janky), from 0–100. + final double missedFramePercent; + + /// Total duration of valid (non-excluded) measurement time. + final Duration validDuration; + + /// Total duration excluded from measurement (background, warmup, etc.). + final Duration excludedDuration; + + /// A breakdown of how many times each [ExclusionReason] occurred. + final Map exclusionReasons; + + /// Device state recorded at the start of the session. + final DeviceStateSnapshot deviceState; + + /// Creates a new [SessionReport]. + const SessionReport({ + required this.sessionName, + required this.verdict, + required this.likelyBottleneck, + required this.targetHz, + required this.observedAvgHz, + required this.frameBudgetMs, + required this.avgFps, + required this.onePercentLowFps, + required this.fivePercentLowFps, + required this.avgBuildMs, + required this.avgRasterMs, + required this.avgTotalFrameMs, + required this.jankyFrameCount, + required this.severeJankCount, + required this.missedFramePercent, + required this.validDuration, + required this.excludedDuration, + required this.exclusionReasons, + required this.deviceState, + }); + + /// Serializes this report to a JSON-compatible map. + Map toMap() => { + 'sessionName': sessionName, + 'verdict': verdict.name, + 'likelyBottleneck': likelyBottleneck.name, + 'targetHz': targetHz, + 'observedAvgHz': observedAvgHz, + 'frameBudgetMs': frameBudgetMs, + 'avgFps': avgFps, + 'onePercentLowFps': onePercentLowFps, + 'fivePercentLowFps': fivePercentLowFps, + 'avgBuildMs': avgBuildMs, + 'avgRasterMs': avgRasterMs, + 'avgTotalFrameMs': avgTotalFrameMs, + 'jankyFrameCount': jankyFrameCount, + 'severeJankCount': severeJankCount, + 'missedFramePercent': missedFramePercent, + 'validDurationMs': validDuration.inMilliseconds, + 'excludedDurationMs': excludedDuration.inMilliseconds, + 'exclusionReasons': exclusionReasons.map((k, v) => MapEntry(k.name, v)), + 'deviceState': deviceState.toMap(), + }; + + /// Serializes this report to a JSON string. + String toJson() => jsonEncode(toMap()); + + /// Serializes this report to a CSV string. + String toCsv() { + final m = toMap(); + String esc(dynamic v) { + final s = v is Map ? jsonEncode(v) : '$v'; + if (s.contains(',') || s.contains('"') || s.contains('\n')) { + return '"${s.replaceAll('"', '""')}"'; + } + return s; + } + final headers = m.keys.join(','); + final values = m.values.map(esc).join(','); + return '$headers\n$values'; + } +} diff --git a/third_party/refresh_rate/lib/src/refresh_rate.dart b/third_party/refresh_rate/lib/src/refresh_rate.dart new file mode 100644 index 00000000..a0a2fe8d --- /dev/null +++ b/third_party/refresh_rate/lib/src/refresh_rate.dart @@ -0,0 +1,246 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; + +import 'generated/refresh_rate_api.g.dart'; +import 'models/display_info.dart'; +import 'models/enums.dart'; +import 'refresh_rate_api_adapter.dart'; +import 'verification/overlay_controller.dart'; +import 'verification/refresh_rate_session.dart'; + +/// Primary entry-point for controlling and monitoring the display refresh rate. +/// +/// All members are static; this class cannot be instantiated. +/// +/// ### Quick-start +/// ```dart +/// // Unlock the highest supported refresh rate. +/// await RefreshRate.refresh(); +/// RefreshRate.preferMax(); +/// +/// // Show an FPS overlay for debugging. +/// RefreshRate.showFPS(); +/// ``` +class RefreshRate { + RefreshRate._(); + + static RefreshRateApiAdapter _api = PigeonRefreshRateApiAdapter(); + static DisplayInfo _cachedInfo = DisplayInfo.fallback; + static StreamController? _changedController; + static final _flutterApi = _RefreshRateFlutterApiImpl(); + + // ── Platform registration ────────────────────────────────────── + + /// Replaces the default platform API adapter. + /// + /// Called by platform-specific entrypoints (e.g. the web plugin) during + /// framework initialisation. Not intended for end-user consumption. + static void registerAdapter(RefreshRateApiAdapter adapter) { + _api = adapter; + } + + // ── Test seam ────────────────────────────────────────────────── + + /// Replaces the platform API implementation with a test fake. + /// + /// Call [clearApiForTesting] in `tearDown` to restore the real adapter. + @visibleForTesting + static void setApiForTesting(RefreshRateApiAdapter api) { + _api = api; + } + + /// Restores the real platform API and resets all internal state. + /// + /// Must be called in `tearDown` after [setApiForTesting]. + @visibleForTesting + static void clearApiForTesting() { + _api = PigeonRefreshRateApiAdapter(); + _flutterApi._onChanged = null; + RefreshRateFlutterApi.setUp(null); + _changedController?.close(); + _changedController = null; + } + + // ── Control ──────────────────────────────────────────────────── + + /// Opts the app into the platform's high-refresh-rate rendering pipeline. + /// + /// On Android this sets `preferredDisplayModeId` on the window surface. + /// On iOS/macOS this adjusts `CADisplayLink` preferred frame rate ranges. + /// Has no effect on platforms that do not support variable refresh rates. + static void enable() { + _api.enable(); + _refreshInfo(); + } + + /// Reverts the app to the platform default (typically 60 Hz). + /// + /// On iOS this allows the OS to manage the frame rate automatically. + static void disable() { + _api.disable(); + _refreshInfo(); + } + + /// Requests the maximum supported refresh rate for this display. + /// + /// Equivalent to calling [enable] then letting the platform pick the ceiling. + static void preferMax() => _api.preferMax(); + + /// Reverts to the display's default / preferred refresh rate. + static void preferDefault() => _api.preferDefault(); + + /// Requests a refresh rate that matches the given [fps] content rate. + /// + /// Useful when playing back video at a fixed frame rate (e.g. 24, 30, 60 fps) + /// so the display cadence aligns with the media cadence. + static void matchContent(double fps) => _api.matchContent(fps); + + /// Temporarily boosts the display to its maximum refresh rate for [duration]. + /// + /// Commonly used when a gesture or animation starts — the display snaps to + /// high-Hz immediately and returns to the preferred rate after the duration. + static void boost(Duration duration) => + _api.boost(duration.inMilliseconds); + + /// Boosts the refresh rate for the lifetime of an [AnimationController]. + /// + /// Registers a status listener that calls [boost] whenever the controller + /// starts animating, and removes itself once the animation is done. + static void boostDuring(AnimationController controller) { + late final void Function(AnimationStatus) statusListener; + statusListener = (status) { + if (status == AnimationStatus.forward || + status == AnimationStatus.reverse) { + _api.boost(controller.duration?.inMilliseconds ?? 500); + } else if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + controller.removeStatusListener(statusListener); + } + }; + controller.addStatusListener(statusListener); + } + + /// Sets a named [RateCategory] hint for the display scheduler. + /// + /// Categories let you declare the performance class of your app + /// (`low`, `normal`, `high`) rather than specifying raw Hz values. + static void category(RateCategory c) => _api.setCategory(c.index); + + /// Enables or disables an automatic boost whenever the user touches the screen. + /// + /// When [enabled] is `true` the platform raises the refresh rate on every + /// touch-down event and lowers it again after a short idle period. + static void setTouchBoost(bool enabled) => _api.setTouchBoost(enabled); + + // ── Verification overlays ────────────────────────────────────── + + /// Shows a minimal live FPS counter overlay in the top-right corner. + static void showFPS() => OverlayController.instance.showFPS(); + + /// Shows a minimal live Hz readout overlay in the top-right corner. + static void showHz() => OverlayController.instance.showHz(); + + /// Shows the full diagnostic overlay (FPS + Hz + thermal state). + static void showOverlay() => OverlayController.instance.showFull(); + + /// Hides whatever verification overlay is currently visible. + static void hideOverlay() => OverlayController.instance.hide(); + + /// Whether the debug overlay is currently shown. + static bool get isOverlayVisible => OverlayController.instance.isVisible; + + // ── Diagnostics ──────────────────────────────────────────────── + + /// The most recently fetched [DisplayInfo] snapshot. + /// + /// Initialised to [DisplayInfo.fallback] (60 Hz, all optional fields null) + /// until [refresh] is awaited at least once. + static DisplayInfo get info => _cachedInfo; + + /// Fetches fresh [DisplayInfo] from the platform and caches the result. + /// + /// Resolves with the updated [DisplayInfo] on success. + static Future refresh() async { + final msg = await _api.getDisplayInfo(); + _cachedInfo = DisplayInfo.fromMessage(msg); + return _cachedInfo; + } + + /// A broadcast stream that emits a new [DisplayInfo] whenever the display + /// configuration changes (e.g. the user enables Low Power Mode, or the + /// device throttles under thermal pressure). + static Stream get onChanged { + if (_changedController == null) { + _changedController = StreamController.broadcast(); + _flutterApi._onChanged = (info) { + _cachedInfo = info; + _changedController!.add(info); + }; + RefreshRateFlutterApi.setUp(_flutterApi); + } + return _changedController!.stream; + } + + /// Whether iOS ProMotion (adaptive 120 Hz) is enabled for this app. + /// + /// Returns `false` until a successful [refresh] is completed and the device + /// is an iPhone/iPad with a ProMotion display. + static bool get isProMotionReady => + _cachedInfo.iosProMotionEnabled == true; + + /// Whether the device is currently in Low Power Mode. + /// + /// Defaults to `false` when the value cannot be determined. + static bool get isLowPowerMode => + _cachedInfo.isLowPowerMode ?? false; + + /// The current thermal state of the device. + /// + /// A state of [ThermalState.serious] or [ThermalState.critical] may cause + /// the OS to clamp the refresh rate regardless of your requested value. + static ThermalState get thermalState => _cachedInfo.thermalState; + + // ── Benchmark sessions ───────────────────────────────────────── + + /// Creates and starts a new FPS benchmark session named [name]. + /// + /// Call [RefreshRateSession.end] when the scenario under test completes to + /// receive a [SessionReport] with verdict, FPS stats, and bottleneck hints. + static RefreshRateSession startSession(String name) { + return RefreshRateSession.create(name, _cachedInfo); + } + + // ── Internal ─────────────────────────────────────────────────── + + static void _refreshInfo() { + void handle(DisplayInfoMessage msg) { + _cachedInfo = DisplayInfo.fromMessage(msg); + } + void handleError(Object e) { + assert(() { + debugPrint('RefreshRate: _refreshInfo error: $e'); + return true; + }()); + } + + try { + final result = _api.getDisplayInfo(); + if (result is Future) { + result.then(handle).catchError(handleError); + } else { + handle(result); + } + } catch (e) { + handleError(e); + } + } +} + +class _RefreshRateFlutterApiImpl extends RefreshRateFlutterApi { + void Function(DisplayInfo)? _onChanged; + + @override + void onDisplayInfoChanged(DisplayInfoMessage info) { + _onChanged?.call(DisplayInfo.fromMessage(info)); + } +} diff --git a/third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart b/third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart new file mode 100644 index 00000000..3152293e --- /dev/null +++ b/third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'generated/refresh_rate_api.g.dart'; + +/// Abstract interface for the host API, used as the test seam. +/// +/// The real implementation ([PigeonRefreshRateApiAdapter]) delegates to the +/// pigeon-generated [RefreshRateHostApi]. Tests implement this interface +/// directly with synchronous fakes — no await required. +abstract class RefreshRateApiAdapter { + /// Fetches the latest display configuration. + FutureOr getDisplayInfo(); + /// Enables High Refresh Rate overrides. + FutureOr enable(); + /// Disables High Refresh Rate overrides. + FutureOr disable(); + /// Requests the highest possible display refresh rate. + FutureOr preferMax(); + /// Resets to the system default refresh rate. + FutureOr preferDefault(); + /// Attempts to set the display refresh rate to match [fps]. + FutureOr matchContent(double fps); + /// Temporarily boosts the refresh rate for [durationMs]. + FutureOr boost(int durationMs); + /// Sets the refresh rate based on a given category. + FutureOr setCategory(int categoryIndex); + /// Enables or disables automatic refresh rate boost on touch interactions. + FutureOr setTouchBoost(bool enabled); + /// Checks whether the refresh rate overrides are supported by the platform. + FutureOr isSupported(); +} + +/// Production implementation that delegates to the pigeon-generated channel. +class PigeonRefreshRateApiAdapter implements RefreshRateApiAdapter { + final RefreshRateHostApi _pigeon; + + /// Creates a new [PigeonRefreshRateApiAdapter]. + PigeonRefreshRateApiAdapter() : _pigeon = RefreshRateHostApi(); + + @override + Future getDisplayInfo() => _pigeon.getDisplayInfo(); + + @override + Future enable() => _pigeon.enable(); + + @override + Future disable() => _pigeon.disable(); + + @override + Future preferMax() => _pigeon.preferMax(); + + @override + Future preferDefault() => _pigeon.preferDefault(); + + @override + Future matchContent(double fps) => _pigeon.matchContent(fps); + + @override + Future boost(int durationMs) => _pigeon.boost(durationMs); + + @override + Future setCategory(int categoryIndex) => _pigeon.setCategory(categoryIndex); + + @override + Future setTouchBoost(bool enabled) => _pigeon.setTouchBoost(enabled); + + @override + Future isSupported() => _pigeon.isSupported(); +} diff --git a/third_party/refresh_rate/lib/src/verification/fps_tracker.dart b/third_party/refresh_rate/lib/src/verification/fps_tracker.dart new file mode 100644 index 00000000..6bf58d03 --- /dev/null +++ b/third_party/refresh_rate/lib/src/verification/fps_tracker.dart @@ -0,0 +1,132 @@ +import 'dart:ui' show FramePhase; +import 'package:flutter/scheduler.dart'; + +/// Represents a single rendered frame's timing data. +class FrameSample { + /// Time spent in the UI build thread, in microseconds. + final int buildUs; + /// Time spent in the raster thread, in microseconds. + final int rasterUs; + /// Total time to render the frame, in microseconds. + final int totalUs; + /// Vsync timestamp, in microseconds. + final int vsyncUs; + /// System time when the sample was recorded. + final DateTime timestamp; + + /// Creates a new [FrameSample]. + FrameSample({ + required this.buildUs, + required this.rasterUs, + required this.totalUs, + required this.vsyncUs, + required this.timestamp, + }); +} + +/// Helper that records [FrameTiming] data and calculates FPS statistics. +class FpsTracker { + final List _samples = []; + + /// The number of recorded samples. + int get sampleCount => _samples.length; + + /// Adds raw frame timings from the Flutter engine. + void addTimings(List timings) { + final now = DateTime.now(); + for (final t in timings) { + _samples.add(FrameSample( + buildUs: t.buildDuration.inMicroseconds, + rasterUs: t.rasterDuration.inMicroseconds, + totalUs: t.totalSpan.inMicroseconds, + vsyncUs: t.timestampInMicroseconds(FramePhase.vsyncStart), + timestamp: now, + )); + } + // Keep a bounded window so memory doesn't grow unbounded during long sessions + if (_samples.length > 600) { + _samples.removeRange(0, _samples.length - 600); + } + } + + /// Clears all recorded samples. + void reset() => _samples.clear(); + + /// Returns an immutable list of currently recorded samples. + List get samples => List.unmodifiable(_samples); + + /// FPS over all samples — used by benchmark sessions. + double get avgFps => _fpsFromWindow(_samples); + + /// FPS over the last [n] frames — used by the live overlay. + double recentFps([int n = 60]) { + if (_samples.length < 2) return 0.0; + final window = _samples.length <= n ? _samples : _samples.sublist(_samples.length - n); + return _fpsFromWindow(window); + } + + static double _fpsFromWindow(List s) { + if (s.length < 2) return 0.0; + final elapsedUs = s.last.vsyncUs - s.first.vsyncUs; + if (elapsedUs <= 0) return 0.0; + return (s.length - 1) * 1000000.0 / elapsedUs; + } + + /// Average duration of the UI build phase across all samples, in ms. + double get avgBuildMs { + if (_samples.isEmpty) return 0.0; + return _samples.fold(0, (s, f) => s + f.buildUs) / _samples.length / 1000.0; + } + + /// Average duration of the raster phase across all samples, in ms. + double get avgRasterMs { + if (_samples.isEmpty) return 0.0; + return _samples.fold(0, (s, f) => s + f.rasterUs) / _samples.length / 1000.0; + } + + /// Average total duration (build + raster) across all samples, in ms. + double get avgTotalMs { + if (_samples.isEmpty) return 0.0; + return _samples.fold(0, (s, f) => s + f.totalUs) / _samples.length / 1000.0; + } + + /// Worst 1-percentile frame rate. + double get onePercentLowFps { + if (_samples.isEmpty) return 0.0; + final sorted = _samples.map((s) => s.totalUs).toList()..sort(); + final cutoff = (sorted.length * 0.99).floor(); + final slowSamples = sorted.skip(cutoff).toList(); + if (slowSamples.isEmpty) return avgFps; + final avgSlowUs = slowSamples.fold(0, (s, v) => s + v) / slowSamples.length; + return avgSlowUs > 0 ? 1000000 / avgSlowUs : 0.0; + } + + /// Worst 5-percentile frame rate. + double get fivePercentLowFps { + if (_samples.isEmpty) return 0.0; + final sorted = _samples.map((s) => s.totalUs).toList()..sort(); + final cutoff = (sorted.length * 0.95).floor(); + final slowSamples = sorted.skip(cutoff).toList(); + if (slowSamples.isEmpty) return avgFps; + final avgSlowUs = slowSamples.fold(0, (s, v) => s + v) / slowSamples.length; + return avgSlowUs > 0 ? 1000000 / avgSlowUs : 0.0; + } + + /// Counts how many frames exceeded the target budget. + int jankyFrameCount(double targetHz) { + final budgetUs = (1000000 / targetHz).round(); + return _samples.where((s) => s.totalUs > budgetUs).length; + } + + /// Counts how many frames took more than twice the target budget. + int severeJankCount(double targetHz) { + final budgetUs = (1000000 / targetHz).round(); + return _samples.where((s) => s.totalUs > budgetUs * 2).length; + } + + /// Percentage of frames that were missed. + double missedFramePercent(double targetHz) { + if (_samples.isEmpty) return 0.0; + return jankyFrameCount(targetHz) / _samples.length * 100.0; + } +} diff --git a/third_party/refresh_rate/lib/src/verification/overlay_controller.dart b/third_party/refresh_rate/lib/src/verification/overlay_controller.dart new file mode 100644 index 00000000..048da7e7 --- /dev/null +++ b/third_party/refresh_rate/lib/src/verification/overlay_controller.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'fps_tracker.dart'; +import 'overlay_widgets.dart'; + +enum _OverlayMode { none, fps, hz, full } + +/// Manages the visibility and rendering of debug overlays. +class OverlayController { + OverlayController._(); + /// The singleton instance of [OverlayController]. + static final instance = OverlayController._(); + + OverlayEntry? _entry; + _OverlayMode _mode = _OverlayMode.none; + final FpsTracker _tracker = FpsTracker(); + TimingsCallback? _timingsCallback; + + /// Whether an overlay is currently visible. + bool get isVisible => _mode != _OverlayMode.none; + + /// Shows a compact FPS overlay. + void showFPS() => _show(_OverlayMode.fps); + /// Shows a compact Hz (refresh rate) overlay. + void showHz() => _show(_OverlayMode.hz); + /// Shows the full debug overlay with detailed metrics. + void showFull() => _show(_OverlayMode.full); + + /// Hides any currently visible overlay. + void hide() { + _entry?.remove(); + _entry = null; + _mode = _OverlayMode.none; + _stopTracking(); + } + + void _show(_OverlayMode mode) { + hide(); + _mode = mode; + _startTracking(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final overlay = _findOverlay(); + if (overlay == null) return; + _entry = OverlayEntry(builder: (_) => _buildWidget()); + overlay.insert(_entry!); + }); + } + + // Walks DOWN the element tree to find the first OverlayState. + // Overlay.maybeOf(rootElement) fails because the Overlay is a descendant, + // not an ancestor, of the root element. + static OverlayState? _findOverlay() { + final root = WidgetsBinding.instance.rootElement; + if (root == null) return null; + OverlayState? found; + void visit(Element element) { + if (found != null) return; + if (element is StatefulElement && element.state is OverlayState) { + found = element.state as OverlayState; + return; + } + element.visitChildren(visit); + } + root.visitChildren(visit); + return found; + } + + Widget _buildWidget() { + final inner = switch (_mode) { + _OverlayMode.fps => FpsOverlayWidget(tracker: _tracker), + _OverlayMode.hz => HzOverlayWidget(tracker: _tracker), + _OverlayMode.full => FullOverlayWidget(tracker: _tracker), + _OverlayMode.none => const SizedBox.shrink(), + }; + return inner; + } + + void _startTracking() { + if (_timingsCallback != null) return; + _timingsCallback = _tracker.addTimings; + SchedulerBinding.instance.addTimingsCallback(_timingsCallback!); + } + + void _stopTracking() { + if (_timingsCallback != null) { + SchedulerBinding.instance.removeTimingsCallback(_timingsCallback!); + _timingsCallback = null; + } + _tracker.reset(); + } +} diff --git a/third_party/refresh_rate/lib/src/verification/overlay_widgets.dart b/third_party/refresh_rate/lib/src/verification/overlay_widgets.dart new file mode 100644 index 00000000..823b772d --- /dev/null +++ b/third_party/refresh_rate/lib/src/verification/overlay_widgets.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import '../models/enums.dart'; +import '../refresh_rate.dart'; +import 'fps_tracker.dart'; + +mixin _OverlayStateMixin on State { + late final Ticker _ticker; + + @override + void initState() { + super.initState(); + _ticker = Ticker((_) => setState(() {})); + _ticker.start(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + Color fpsColor(double fps, double targetHz) { + if (fps >= targetHz * 0.95) return const Color(0xFF4CAF50); + if (fps >= targetHz * 0.75) return const Color(0xFFFFC107); + return const Color(0xFFF44336); + } + + Widget overlayContainer({required Widget child}) { + return Positioned( + top: MediaQueryData.fromView(View.of(context)).padding.top + 4, + right: 8, + child: IgnorePointer( + child: Material( + type: MaterialType.transparency, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xDD000000), + borderRadius: BorderRadius.circular(6), + ), + child: child, + ), + ), + ), + ); + } +} + +// ── FPS Badge ───────────────────────────────────────────────────── + +/// A small overlay widget that displays the current FPS. +class FpsOverlayWidget extends StatefulWidget { + /// The [FpsTracker] providing frame timings. + final FpsTracker tracker; + /// Creates a [FpsOverlayWidget]. + const FpsOverlayWidget({super.key, required this.tracker}); + @override + State createState() => _FpsOverlayWidgetState(); +} + +class _FpsOverlayWidgetState extends State + with _OverlayStateMixin { + @override + Widget build(BuildContext context) { + final fps = widget.tracker.recentFps(); + return overlayContainer( + child: Text( + '${fps.toStringAsFixed(0)} FPS', + style: TextStyle( + color: fpsColor(fps, RefreshRate.info.maxRate), + fontSize: 13, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ); + } +} + +// ── Hz Badge ────────────────────────────────────────────────────── + +/// A small overlay widget that displays the current display refresh rate. +class HzOverlayWidget extends StatefulWidget { + /// The [FpsTracker] (unused but required for generic widget creation). + final FpsTracker tracker; + /// Creates a [HzOverlayWidget]. + const HzOverlayWidget({super.key, required this.tracker}); + @override + State createState() => _HzOverlayWidgetState(); +} + +class _HzOverlayWidgetState extends State + with _OverlayStateMixin { + @override + Widget build(BuildContext context) { + final hz = RefreshRate.info.currentRate; + return overlayContainer( + child: Text( + '${hz.toStringAsFixed(0)}Hz', + style: const TextStyle( + color: Color(0xFF64B5F6), + fontSize: 13, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ); + } +} + +// ── Full Diagnostic Overlay ──────────────────────────────────────── + +/// A larger overlay widget that displays FPS, build/raster times, and device health. +class FullOverlayWidget extends StatefulWidget { + /// The [FpsTracker] providing frame timings. + final FpsTracker tracker; + /// Creates a [FullOverlayWidget]. + const FullOverlayWidget({super.key, required this.tracker}); + @override + State createState() => _FullOverlayWidgetState(); +} + +class _FullOverlayWidgetState extends State + with _OverlayStateMixin { + @override + Widget build(BuildContext context) { + final fps = widget.tracker.recentFps(); + final buildMs = widget.tracker.avgBuildMs; + final rasterMs = widget.tracker.avgRasterMs; + final targetHz = RefreshRate.info.maxRate; + final budgetMs = 1000.0 / targetHz; + + return overlayContainer( + child: DefaultTextStyle( + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 11, + fontFeatures: [FontFeature.tabularFigures()], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${fps.toStringAsFixed(0)} FPS', + style: TextStyle( + color: fpsColor(fps, targetHz), + fontSize: 14, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text('build ${buildMs.toStringAsFixed(1)}ms raster ${rasterMs.toStringAsFixed(1)}ms'), + Text('budget ${budgetMs.toStringAsFixed(1)}ms @ ${targetHz.toStringAsFixed(0)}Hz'), + if (RefreshRate.isLowPowerMode) + const Text( + '\u26a1 Low Power', + style: TextStyle(color: Color(0xFFFFCC02)), + ), + if (RefreshRate.thermalState != ThermalState.nominal && + RefreshRate.thermalState != ThermalState.unknown) + Text( + 'thermal: ${RefreshRate.thermalState.name}', + style: const TextStyle(color: Color(0xFFFF7043)), + ), + ], + ), + ), + ); + } +} diff --git a/third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart b/third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart new file mode 100644 index 00000000..139295c5 --- /dev/null +++ b/third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart @@ -0,0 +1,135 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import '../models/display_info.dart'; +import '../models/enums.dart'; +import '../models/session_report.dart'; +import 'fps_tracker.dart'; +import 'session_scorer.dart'; + +/// An active FPS benchmark session that records frame timings. +/// +/// Create via [RefreshRate.startSession] and call [end] to receive a +/// [SessionReport] with verdict, FPS stats, and bottleneck analysis. +/// +/// The session automatically pauses when the app goes to the background and +/// resumes (with a warm-up exclusion window) when the app returns to the +/// foreground. +class RefreshRateSession { + /// The human-readable name given to this session. + final String name; + SessionState _state; + final DateTime _startedAt; + final double _targetHz; + final DisplayInfo _initialInfo; + final FpsTracker _tracker = FpsTracker(); + Duration _excludedDuration = Duration.zero; + final Map _exclusions = {}; + TimingsCallback? _timingsCallback; + AppLifecycleListener? _lifecycleListener; + DateTime? _inactiveAt; + static const _warmupDuration = Duration(milliseconds: 500); + + RefreshRateSession._({ + required this.name, + required SessionState state, + required DateTime startedAt, + required double targetHz, + required DisplayInfo initialInfo, + }) : _state = state, + _startedAt = startedAt, + _targetHz = targetHz, + _initialInfo = initialInfo; + + /// Creates and starts a new tracking session. + static RefreshRateSession create(String name, DisplayInfo info) { + final session = RefreshRateSession._( + name: name, + state: SessionState.running, + startedAt: DateTime.now(), + targetHz: info.maxRate, + initialInfo: info, + ); + session._timingsCallback = session._tracker.addTimings; + SchedulerBinding.instance.addTimingsCallback(session._timingsCallback!); + session._registerLifecycleObserver(); + return session; + } + + /// Current lifecycle state of the session. + SessionState get state => _state; + + /// The time at which this session was created. + DateTime get startedAt => _startedAt; + + /// The target refresh rate in Hz (taken from [DisplayInfo.maxRate] at creation time). + double get targetHz => _targetHz; + + /// Stops frame timing collection and computes a [SessionReport]. + /// + /// Must be called exactly once. After this call the session is in + /// [SessionState.completed] and further calls have undefined behaviour. + Future end() async { + _stopTracking(); + _state = SessionState.completed; + return SessionScorer.compute( + sessionName: name, + tracker: _tracker, + targetHz: _targetHz, + validDuration: DateTime.now().difference(_startedAt) - _excludedDuration, + excludedDuration: _excludedDuration, + exclusionReasons: Map.unmodifiable(_exclusions), + deviceState: DeviceStateSnapshot( + isLowPowerMode: _initialInfo.isLowPowerMode, + thermalState: _initialInfo.thermalState, + hasAdaptiveRefreshRate: _initialInfo.hasAdaptiveRefreshRate, + displayServer: _initialInfo.displayServer, + monitorCount: _initialInfo.monitorCount, + ), + ); + } + + void _registerLifecycleObserver() { + _lifecycleListener = AppLifecycleListener( + onHide: _onInactive, + onInactive: _onInactive, + onPause: _onInactive, + onResume: _onResume, + ); + } + + void _onInactive() { + if (_state != SessionState.running) return; + _state = SessionState.interrupted; + _inactiveAt = DateTime.now(); + _addExclusion(ExclusionReason.appBackgrounded); + } + + void _onResume() { + if (_state != SessionState.interrupted) return; + if (_inactiveAt != null) { + _excludedDuration += DateTime.now().difference(_inactiveAt!); + _inactiveAt = null; + } + _state = SessionState.warmup; + Future.delayed(_warmupDuration, () { + if (_state == SessionState.warmup) { + _excludedDuration += _warmupDuration; + _addExclusion(ExclusionReason.resumeWarmup); + _state = SessionState.running; + } + }); + } + + void _addExclusion(ExclusionReason reason) { + _exclusions[reason] = (_exclusions[reason] ?? 0) + 1; + } + + void _stopTracking() { + if (_timingsCallback != null) { + SchedulerBinding.instance.removeTimingsCallback(_timingsCallback!); + _timingsCallback = null; + } + _lifecycleListener?.dispose(); + _lifecycleListener = null; + } +} diff --git a/third_party/refresh_rate/lib/src/verification/session_scorer.dart b/third_party/refresh_rate/lib/src/verification/session_scorer.dart new file mode 100644 index 00000000..12494cd8 --- /dev/null +++ b/third_party/refresh_rate/lib/src/verification/session_scorer.dart @@ -0,0 +1,94 @@ +import '../models/enums.dart'; +import '../models/session_report.dart'; +import 'fps_tracker.dart'; + +/// Utility class that calculates the final [SessionReport] metrics. +abstract class SessionScorer { + /// Computes a [SessionReport] from the recorded tracking data. + static SessionReport compute({ + required String sessionName, + required FpsTracker tracker, + required double targetHz, + required Duration validDuration, + required Duration excludedDuration, + required Map exclusionReasons, + required DeviceStateSnapshot deviceState, + }) { + final frameBudgetMs = 1000.0 / targetHz; + final avgFps = tracker.avgFps; + final missed = tracker.missedFramePercent(targetHz); + final one = tracker.onePercentLowFps; + final five = tracker.fivePercentLowFps; + final janky = tracker.jankyFrameCount(targetHz); + final severe = tracker.severeJankCount(targetHz); + final avgBuild = tracker.avgBuildMs; + final avgRaster = tracker.avgRasterMs; + final avgTotal = tracker.avgTotalMs; + + final verdict = _computeVerdict(avgFps, targetHz, missed); + final bottleneck = _computeBottleneck( + avgBuild: avgBuild, + avgRaster: avgRaster, + avgFps: avgFps, + targetHz: targetHz, + isLowPowerMode: deviceState.isLowPowerMode, + thermalState: deviceState.thermalState, + ); + + return SessionReport( + sessionName: sessionName, + verdict: verdict, + likelyBottleneck: bottleneck, + targetHz: targetHz, + observedAvgHz: avgFps, + frameBudgetMs: frameBudgetMs, + avgFps: avgFps, + onePercentLowFps: one, + fivePercentLowFps: five, + avgBuildMs: avgBuild, + avgRasterMs: avgRaster, + avgTotalFrameMs: avgTotal, + jankyFrameCount: janky, + severeJankCount: severe, + missedFramePercent: missed, + validDuration: validDuration, + excludedDuration: excludedDuration, + exclusionReasons: exclusionReasons, + deviceState: deviceState, + ); + } + + static Verdict _computeVerdict(double avgFps, double targetHz, double missedPct) { + if (avgFps == 0) return Verdict.inconclusive; + final hitRate = avgFps / targetHz; + if (hitRate >= 0.95 && missedPct < 2) return Verdict.excellent; + if (hitRate >= 0.85 && missedPct < 5) return Verdict.good; + if (hitRate >= 0.70 && missedPct < 15) return Verdict.fair; + return Verdict.poor; + } + + static Bottleneck _computeBottleneck({ + required double avgBuild, + required double avgRaster, + required double avgFps, + required double targetHz, + required bool? isLowPowerMode, + required ThermalState thermalState, + }) { + if (isLowPowerMode == true) return Bottleneck.powerLimited; + if (thermalState == ThermalState.serious || thermalState == ThermalState.critical) { + return Bottleneck.thermalLimited; + } + final budgetMs = 1000.0 / targetHz; + // 85-90% of target: likely a display-side cap (LTPO floor, VSync rounding, etc.) + if (avgFps >= targetHz * 0.85 && avgFps < targetHz * 0.95) { + return Bottleneck.displayCapped; + } + // Below 85%: check for build vs raster bottleneck + if (avgFps < targetHz * 0.85) { + if (avgBuild > avgRaster && avgBuild > budgetMs * 0.5) return Bottleneck.buildBound; + if (avgRaster > budgetMs * 0.5) return Bottleneck.rasterBound; + } + return Bottleneck.none; + } +} diff --git a/third_party/refresh_rate/lib/src/web/raf_hz_detector.dart b/third_party/refresh_rate/lib/src/web/raf_hz_detector.dart new file mode 100644 index 00000000..70f2fec1 --- /dev/null +++ b/third_party/refresh_rate/lib/src/web/raf_hz_detector.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +/// Detects the display's refresh rate by measuring +/// `requestAnimationFrame` callback intervals. +/// +/// Uses the same technique as TestUFO: collect a window of rAF +/// timestamps, compute the median inter-frame interval, and derive Hz. +class RafHzDetector { + /// Number of frames to sample before reporting a result. + static const int _kSampleCount = 120; + + /// Measures the current display refresh rate via rAF timing. + /// + /// Collects [_kSampleCount] frames, computes the median interval, + /// and returns `1000 / medianMs`. Returns `null` if measurement fails. + static Future measure() { + final completer = Completer(); + final timestamps = []; + late final JSFunction callback; + + void onFrame(JSNumber ts) { + timestamps.add(ts.toDartDouble); + + if (timestamps.length >= _kSampleCount + 1) { + // Compute intervals + final intervals = []; + for (var i = 1; i < timestamps.length; i++) { + intervals.add(timestamps[i] - timestamps[i - 1]); + } + intervals.sort(); + final median = intervals[intervals.length ~/ 2]; + + if (median > 0) { + // Round to nearest common Hz value (e.g. 60, 90, 120, 144, 165, 240) + final rawHz = 1000.0 / median; + completer.complete(_snapToCommonHz(rawHz)); + } else { + completer.complete(null); + } + } else { + web.window.requestAnimationFrame(callback); + } + } + + callback = onFrame.toJS; + web.window.requestAnimationFrame(callback); + + return completer.future; + } + + /// Snaps a raw Hz measurement to the nearest common display refresh rate + /// when the raw value is within 3% tolerance. + static double _snapToCommonHz(double raw) { + const commonRates = [30.0, 48.0, 60.0, 72.0, 90.0, 120.0, 144.0, 165.0, 240.0, 360.0]; + for (final rate in commonRates) { + if ((raw - rate).abs() / rate < 0.03) return rate; + } + return double.parse(raw.toStringAsFixed(1)); + } +} diff --git a/third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart b/third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart new file mode 100644 index 00000000..96d0615b --- /dev/null +++ b/third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import '../generated/refresh_rate_api.g.dart'; +import '../refresh_rate_api_adapter.dart'; +import 'raf_hz_detector.dart'; + +/// Web implementation of [RefreshRateApiAdapter]. +/// +/// Uses `requestAnimationFrame` interval timing to detect the display's +/// current refresh rate. Control methods are graceful no-ops because +/// browsers own their vsync scheduling and expose no API to change it. +class WebRefreshRateApiAdapter implements RefreshRateApiAdapter { + double? _lastMeasuredRate; + + @override + Future getDisplayInfo() async { + _lastMeasuredRate = await RafHzDetector.measure(); + return DisplayInfoMessage( + currentRate: _lastMeasuredRate, + // Browsers don't expose max/min/supported rates. + maxRate: _lastMeasuredRate, + minRate: null, + supportedRates: _lastMeasuredRate != null ? [_lastMeasuredRate!] : null, + isVariableRefreshRate: null, + engineTargetRate: _lastMeasuredRate, + // Apple / Android specific — not applicable on web. + iosProMotionEnabled: null, + androidApiLevel: null, + // No battery/thermal APIs on web. + isLowPowerMode: null, + thermalStateIndex: null, + hasAdaptiveRefreshRate: null, + displayServer: 'web', + monitorCount: 1, + ); + } + + /// No-op — browsers manage their own vsync scheduling. + @override + FutureOr enable() {} + + /// No-op — browsers manage their own vsync scheduling. + @override + FutureOr disable() {} + + /// No-op — cannot request max rate on web. + @override + FutureOr preferMax() {} + + /// No-op — cannot change rate preference on web. + @override + FutureOr preferDefault() {} + + /// No-op — cannot match content frame rate on web. + @override + FutureOr matchContent(double fps) {} + + /// No-op — cannot boost refresh rate on web. + @override + FutureOr boost(int durationMs) {} + + /// No-op — rate categories are Android-specific. + @override + FutureOr setCategory(int categoryIndex) {} + + /// No-op — touch boost is Android-specific. + @override + FutureOr setTouchBoost(bool enabled) {} + + /// Web supports querying the refresh rate but not controlling it. + @override + FutureOr isSupported() => false; +} diff --git a/third_party/refresh_rate/linux/CMakeLists.txt b/third_party/refresh_rate/linux/CMakeLists.txt new file mode 100644 index 00000000..3f93096e --- /dev/null +++ b/third_party/refresh_rate/linux/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "refresh_rate") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "${PROJECT_NAME}_plugin") + +add_library(${PLUGIN_NAME} SHARED + "refresh_rate_plugin.cc" +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) diff --git a/third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h b/third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h new file mode 100644 index 00000000..7d23107b --- /dev/null +++ b/third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h @@ -0,0 +1,22 @@ +#ifndef FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_H_ +#define FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +G_DECLARE_FINAL_TYPE(RefreshRatePlugin, refresh_rate_plugin, REFRESH_RATE, + PLUGIN, GObject) + +FLUTTER_PLUGIN_EXPORT void refresh_rate_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_H_ diff --git a/third_party/refresh_rate/linux/refresh_rate_api.g.cc b/third_party/refresh_rate/linux/refresh_rate_api.g.cc new file mode 100644 index 00000000..f4911e8a --- /dev/null +++ b/third_party/refresh_rate/linux/refresh_rate_api.g.cc @@ -0,0 +1,651 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#undef _HAS_EXCEPTIONS + +#include "refresh_rate_api.g.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace refresh_rate { +using flutter::BasicMessageChannel; +using flutter::CustomEncodableValue; +using flutter::EncodableList; +using flutter::EncodableMap; +using flutter::EncodableValue; + +FlutterError CreateConnectionError(const std::string channel_name) { + return FlutterError( + "channel-error", + "Unable to establish connection on channel: '" + channel_name + "'.", + EncodableValue("")); +} + +// DisplayInfoMessage + +DisplayInfoMessage::DisplayInfoMessage() {} + +DisplayInfoMessage::DisplayInfoMessage( + const double* current_rate, + const double* max_rate, + const double* min_rate, + const EncodableList* supported_rates, + const bool* is_variable_refresh_rate, + const double* engine_target_rate, + const bool* ios_pro_motion_enabled, + const int64_t* android_api_level, + const bool* is_low_power_mode, + const int64_t* thermal_state_index, + const bool* has_adaptive_refresh_rate, + const std::string* display_server, + const int64_t* monitor_count) + : current_rate_(current_rate ? std::optional(*current_rate) : std::nullopt), + max_rate_(max_rate ? std::optional(*max_rate) : std::nullopt), + min_rate_(min_rate ? std::optional(*min_rate) : std::nullopt), + supported_rates_(supported_rates ? std::optional(*supported_rates) : std::nullopt), + is_variable_refresh_rate_(is_variable_refresh_rate ? std::optional(*is_variable_refresh_rate) : std::nullopt), + engine_target_rate_(engine_target_rate ? std::optional(*engine_target_rate) : std::nullopt), + ios_pro_motion_enabled_(ios_pro_motion_enabled ? std::optional(*ios_pro_motion_enabled) : std::nullopt), + android_api_level_(android_api_level ? std::optional(*android_api_level) : std::nullopt), + is_low_power_mode_(is_low_power_mode ? std::optional(*is_low_power_mode) : std::nullopt), + thermal_state_index_(thermal_state_index ? std::optional(*thermal_state_index) : std::nullopt), + has_adaptive_refresh_rate_(has_adaptive_refresh_rate ? std::optional(*has_adaptive_refresh_rate) : std::nullopt), + display_server_(display_server ? std::optional(*display_server) : std::nullopt), + monitor_count_(monitor_count ? std::optional(*monitor_count) : std::nullopt) {} + +const double* DisplayInfoMessage::current_rate() const { + return current_rate_ ? &(*current_rate_) : nullptr; +} + +void DisplayInfoMessage::set_current_rate(const double* value_arg) { + current_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_current_rate(double value_arg) { + current_rate_ = value_arg; +} + + +const double* DisplayInfoMessage::max_rate() const { + return max_rate_ ? &(*max_rate_) : nullptr; +} + +void DisplayInfoMessage::set_max_rate(const double* value_arg) { + max_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_max_rate(double value_arg) { + max_rate_ = value_arg; +} + + +const double* DisplayInfoMessage::min_rate() const { + return min_rate_ ? &(*min_rate_) : nullptr; +} + +void DisplayInfoMessage::set_min_rate(const double* value_arg) { + min_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_min_rate(double value_arg) { + min_rate_ = value_arg; +} + + +const EncodableList* DisplayInfoMessage::supported_rates() const { + return supported_rates_ ? &(*supported_rates_) : nullptr; +} + +void DisplayInfoMessage::set_supported_rates(const EncodableList* value_arg) { + supported_rates_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_supported_rates(const EncodableList& value_arg) { + supported_rates_ = value_arg; +} + + +const bool* DisplayInfoMessage::is_variable_refresh_rate() const { + return is_variable_refresh_rate_ ? &(*is_variable_refresh_rate_) : nullptr; +} + +void DisplayInfoMessage::set_is_variable_refresh_rate(const bool* value_arg) { + is_variable_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_is_variable_refresh_rate(bool value_arg) { + is_variable_refresh_rate_ = value_arg; +} + + +const double* DisplayInfoMessage::engine_target_rate() const { + return engine_target_rate_ ? &(*engine_target_rate_) : nullptr; +} + +void DisplayInfoMessage::set_engine_target_rate(const double* value_arg) { + engine_target_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_engine_target_rate(double value_arg) { + engine_target_rate_ = value_arg; +} + + +const bool* DisplayInfoMessage::ios_pro_motion_enabled() const { + return ios_pro_motion_enabled_ ? &(*ios_pro_motion_enabled_) : nullptr; +} + +void DisplayInfoMessage::set_ios_pro_motion_enabled(const bool* value_arg) { + ios_pro_motion_enabled_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_ios_pro_motion_enabled(bool value_arg) { + ios_pro_motion_enabled_ = value_arg; +} + + +const int64_t* DisplayInfoMessage::android_api_level() const { + return android_api_level_ ? &(*android_api_level_) : nullptr; +} + +void DisplayInfoMessage::set_android_api_level(const int64_t* value_arg) { + android_api_level_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_android_api_level(int64_t value_arg) { + android_api_level_ = value_arg; +} + + +const bool* DisplayInfoMessage::is_low_power_mode() const { + return is_low_power_mode_ ? &(*is_low_power_mode_) : nullptr; +} + +void DisplayInfoMessage::set_is_low_power_mode(const bool* value_arg) { + is_low_power_mode_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_is_low_power_mode(bool value_arg) { + is_low_power_mode_ = value_arg; +} + + +const int64_t* DisplayInfoMessage::thermal_state_index() const { + return thermal_state_index_ ? &(*thermal_state_index_) : nullptr; +} + +void DisplayInfoMessage::set_thermal_state_index(const int64_t* value_arg) { + thermal_state_index_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_thermal_state_index(int64_t value_arg) { + thermal_state_index_ = value_arg; +} + + +const bool* DisplayInfoMessage::has_adaptive_refresh_rate() const { + return has_adaptive_refresh_rate_ ? &(*has_adaptive_refresh_rate_) : nullptr; +} + +void DisplayInfoMessage::set_has_adaptive_refresh_rate(const bool* value_arg) { + has_adaptive_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_has_adaptive_refresh_rate(bool value_arg) { + has_adaptive_refresh_rate_ = value_arg; +} + + +const std::string* DisplayInfoMessage::display_server() const { + return display_server_ ? &(*display_server_) : nullptr; +} + +void DisplayInfoMessage::set_display_server(const std::string_view* value_arg) { + display_server_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_display_server(std::string_view value_arg) { + display_server_ = value_arg; +} + + +const int64_t* DisplayInfoMessage::monitor_count() const { + return monitor_count_ ? &(*monitor_count_) : nullptr; +} + +void DisplayInfoMessage::set_monitor_count(const int64_t* value_arg) { + monitor_count_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_monitor_count(int64_t value_arg) { + monitor_count_ = value_arg; +} + + +EncodableList DisplayInfoMessage::ToEncodableList() const { + EncodableList list; + list.reserve(13); + list.push_back(current_rate_ ? EncodableValue(*current_rate_) : EncodableValue()); + list.push_back(max_rate_ ? EncodableValue(*max_rate_) : EncodableValue()); + list.push_back(min_rate_ ? EncodableValue(*min_rate_) : EncodableValue()); + list.push_back(supported_rates_ ? EncodableValue(*supported_rates_) : EncodableValue()); + list.push_back(is_variable_refresh_rate_ ? EncodableValue(*is_variable_refresh_rate_) : EncodableValue()); + list.push_back(engine_target_rate_ ? EncodableValue(*engine_target_rate_) : EncodableValue()); + list.push_back(ios_pro_motion_enabled_ ? EncodableValue(*ios_pro_motion_enabled_) : EncodableValue()); + list.push_back(android_api_level_ ? EncodableValue(*android_api_level_) : EncodableValue()); + list.push_back(is_low_power_mode_ ? EncodableValue(*is_low_power_mode_) : EncodableValue()); + list.push_back(thermal_state_index_ ? EncodableValue(*thermal_state_index_) : EncodableValue()); + list.push_back(has_adaptive_refresh_rate_ ? EncodableValue(*has_adaptive_refresh_rate_) : EncodableValue()); + list.push_back(display_server_ ? EncodableValue(*display_server_) : EncodableValue()); + list.push_back(monitor_count_ ? EncodableValue(*monitor_count_) : EncodableValue()); + return list; +} + +DisplayInfoMessage DisplayInfoMessage::FromEncodableList(const EncodableList& list) { + DisplayInfoMessage decoded; + auto& encodable_current_rate = list[0]; + if (!encodable_current_rate.IsNull()) { + decoded.set_current_rate(std::get(encodable_current_rate)); + } + auto& encodable_max_rate = list[1]; + if (!encodable_max_rate.IsNull()) { + decoded.set_max_rate(std::get(encodable_max_rate)); + } + auto& encodable_min_rate = list[2]; + if (!encodable_min_rate.IsNull()) { + decoded.set_min_rate(std::get(encodable_min_rate)); + } + auto& encodable_supported_rates = list[3]; + if (!encodable_supported_rates.IsNull()) { + decoded.set_supported_rates(std::get(encodable_supported_rates)); + } + auto& encodable_is_variable_refresh_rate = list[4]; + if (!encodable_is_variable_refresh_rate.IsNull()) { + decoded.set_is_variable_refresh_rate(std::get(encodable_is_variable_refresh_rate)); + } + auto& encodable_engine_target_rate = list[5]; + if (!encodable_engine_target_rate.IsNull()) { + decoded.set_engine_target_rate(std::get(encodable_engine_target_rate)); + } + auto& encodable_ios_pro_motion_enabled = list[6]; + if (!encodable_ios_pro_motion_enabled.IsNull()) { + decoded.set_ios_pro_motion_enabled(std::get(encodable_ios_pro_motion_enabled)); + } + auto& encodable_android_api_level = list[7]; + if (!encodable_android_api_level.IsNull()) { + decoded.set_android_api_level(std::get(encodable_android_api_level)); + } + auto& encodable_is_low_power_mode = list[8]; + if (!encodable_is_low_power_mode.IsNull()) { + decoded.set_is_low_power_mode(std::get(encodable_is_low_power_mode)); + } + auto& encodable_thermal_state_index = list[9]; + if (!encodable_thermal_state_index.IsNull()) { + decoded.set_thermal_state_index(std::get(encodable_thermal_state_index)); + } + auto& encodable_has_adaptive_refresh_rate = list[10]; + if (!encodable_has_adaptive_refresh_rate.IsNull()) { + decoded.set_has_adaptive_refresh_rate(std::get(encodable_has_adaptive_refresh_rate)); + } + auto& encodable_display_server = list[11]; + if (!encodable_display_server.IsNull()) { + decoded.set_display_server(std::get(encodable_display_server)); + } + auto& encodable_monitor_count = list[12]; + if (!encodable_monitor_count.IsNull()) { + decoded.set_monitor_count(std::get(encodable_monitor_count)); + } + return decoded; +} + + +PigeonInternalCodecSerializer::PigeonInternalCodecSerializer() {} + +EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( + uint8_t type, + flutter::ByteStreamReader* stream) const { + switch (type) { + case 129: { + return CustomEncodableValue(DisplayInfoMessage::FromEncodableList(std::get(ReadValue(stream)))); + } + default: + return flutter::StandardCodecSerializer::ReadValueOfType(type, stream); + } +} + +void PigeonInternalCodecSerializer::WriteValue( + const EncodableValue& value, + flutter::ByteStreamWriter* stream) const { + if (const CustomEncodableValue* custom_value = std::get_if(&value)) { + if (custom_value->type() == typeid(DisplayInfoMessage)) { + stream->WriteByte(129); + WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); + return; + } + } + flutter::StandardCodecSerializer::WriteValue(value, stream); +} + +/// The codec used by RefreshRateHostApi. +const flutter::StandardMessageCodec& RefreshRateHostApi::GetCodec() { + return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); +} + +// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. +void RefreshRateHostApi::SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api) { + RefreshRateHostApi::SetUp(binary_messenger, api, ""); +} + +void RefreshRateHostApi::SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api, + const std::string& message_channel_suffix) { + const std::string prepended_suffix = message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : ""; + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + ErrorOr output = api->GetDisplayInfo(); + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->Enable(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->Disable(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->PreferMax(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->PreferDefault(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_fps_arg = args.at(0); + if (encodable_fps_arg.IsNull()) { + reply(WrapError("fps_arg unexpectedly null.")); + return; + } + const auto& fps_arg = std::get(encodable_fps_arg); + std::optional output = api->MatchContent(fps_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_duration_ms_arg = args.at(0); + if (encodable_duration_ms_arg.IsNull()) { + reply(WrapError("duration_ms_arg unexpectedly null.")); + return; + } + const int64_t duration_ms_arg = encodable_duration_ms_arg.LongValue(); + std::optional output = api->Boost(duration_ms_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_category_index_arg = args.at(0); + if (encodable_category_index_arg.IsNull()) { + reply(WrapError("category_index_arg unexpectedly null.")); + return; + } + const int64_t category_index_arg = encodable_category_index_arg.LongValue(); + std::optional output = api->SetCategory(category_index_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_enabled_arg = args.at(0); + if (encodable_enabled_arg.IsNull()) { + reply(WrapError("enabled_arg unexpectedly null.")); + return; + } + const auto& enabled_arg = std::get(encodable_enabled_arg); + std::optional output = api->SetTouchBoost(enabled_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + ErrorOr output = api->IsSupported(); + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } +} + +EncodableValue RefreshRateHostApi::WrapError(std::string_view error_message) { + return EncodableValue(EncodableList{ + EncodableValue(std::string(error_message)), + EncodableValue("Error"), + EncodableValue() + }); +} + +EncodableValue RefreshRateHostApi::WrapError(const FlutterError& error) { + return EncodableValue(EncodableList{ + EncodableValue(error.code()), + EncodableValue(error.message()), + error.details() + }); +} + +// Generated class from Pigeon that represents Flutter messages that can be called from C++. +RefreshRateFlutterApi::RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger) + : binary_messenger_(binary_messenger), + message_channel_suffix_("") {} + +RefreshRateFlutterApi::RefreshRateFlutterApi( + flutter::BinaryMessenger* binary_messenger, + const std::string& message_channel_suffix) + : binary_messenger_(binary_messenger), + message_channel_suffix_(message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : "") {} + +const flutter::StandardMessageCodec& RefreshRateFlutterApi::GetCodec() { + return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); +} + +void RefreshRateFlutterApi::OnDisplayInfoChanged( + const DisplayInfoMessage& info_arg, + std::function&& on_success, + std::function&& on_error) { + const std::string channel_name = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged" + message_channel_suffix_; + BasicMessageChannel<> channel(binary_messenger_, channel_name, &GetCodec()); + EncodableValue encoded_api_arguments = EncodableValue(EncodableList{ + CustomEncodableValue(info_arg), + }); + channel.Send(encoded_api_arguments, [channel_name, on_success = std::move(on_success), on_error = std::move(on_error)](const uint8_t* reply, size_t reply_size) { + std::unique_ptr response = GetCodec().DecodeMessage(reply, reply_size); + const auto& encodable_return_value = *response; + const auto* list_return_value = std::get_if(&encodable_return_value); + if (list_return_value) { + if (list_return_value->size() > 1) { + on_error(FlutterError(std::get(list_return_value->at(0)), std::get(list_return_value->at(1)), list_return_value->at(2))); + } else { + on_success(); + } + } else { + on_error(CreateConnectionError(channel_name)); + } + }); +} + +} // namespace refresh_rate diff --git a/third_party/refresh_rate/linux/refresh_rate_api.g.h b/third_party/refresh_rate/linux/refresh_rate_api.g.h new file mode 100644 index 00000000..70ef60a0 --- /dev/null +++ b/third_party/refresh_rate/linux/refresh_rate_api.g.h @@ -0,0 +1,233 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#ifndef PIGEON_REFRESH_RATE_API_G_H_ +#define PIGEON_REFRESH_RATE_API_G_H_ +#include +#include +#include +#include + +#include +#include +#include + +namespace refresh_rate { + + +// Generated class from Pigeon. + +class FlutterError { + public: + explicit FlutterError(const std::string& code) + : code_(code) {} + explicit FlutterError(const std::string& code, const std::string& message) + : code_(code), message_(message) {} + explicit FlutterError(const std::string& code, const std::string& message, const flutter::EncodableValue& details) + : code_(code), message_(message), details_(details) {} + + const std::string& code() const { return code_; } + const std::string& message() const { return message_; } + const flutter::EncodableValue& details() const { return details_; } + + private: + std::string code_; + std::string message_; + flutter::EncodableValue details_; +}; + +template class ErrorOr { + public: + ErrorOr(const T& rhs) : v_(rhs) {} + ErrorOr(const T&& rhs) : v_(std::move(rhs)) {} + ErrorOr(const FlutterError& rhs) : v_(rhs) {} + ErrorOr(const FlutterError&& rhs) : v_(std::move(rhs)) {} + + bool has_error() const { return std::holds_alternative(v_); } + const T& value() const { return std::get(v_); }; + const FlutterError& error() const { return std::get(v_); }; + + private: + friend class RefreshRateHostApi; + friend class RefreshRateFlutterApi; + ErrorOr() = default; + T TakeValue() && { return std::get(std::move(v_)); } + + std::variant v_; +}; + + + +// Generated class from Pigeon that represents data sent in messages. +class DisplayInfoMessage { + public: + // Constructs an object setting all non-nullable fields. + DisplayInfoMessage(); + + // Constructs an object setting all fields. + explicit DisplayInfoMessage( + const double* current_rate, + const double* max_rate, + const double* min_rate, + const flutter::EncodableList* supported_rates, + const bool* is_variable_refresh_rate, + const double* engine_target_rate, + const bool* ios_pro_motion_enabled, + const int64_t* android_api_level, + const bool* is_low_power_mode, + const int64_t* thermal_state_index, + const bool* has_adaptive_refresh_rate, + const std::string* display_server, + const int64_t* monitor_count); + + const double* current_rate() const; + void set_current_rate(const double* value_arg); + void set_current_rate(double value_arg); + + const double* max_rate() const; + void set_max_rate(const double* value_arg); + void set_max_rate(double value_arg); + + const double* min_rate() const; + void set_min_rate(const double* value_arg); + void set_min_rate(double value_arg); + + const flutter::EncodableList* supported_rates() const; + void set_supported_rates(const flutter::EncodableList* value_arg); + void set_supported_rates(const flutter::EncodableList& value_arg); + + const bool* is_variable_refresh_rate() const; + void set_is_variable_refresh_rate(const bool* value_arg); + void set_is_variable_refresh_rate(bool value_arg); + + const double* engine_target_rate() const; + void set_engine_target_rate(const double* value_arg); + void set_engine_target_rate(double value_arg); + + const bool* ios_pro_motion_enabled() const; + void set_ios_pro_motion_enabled(const bool* value_arg); + void set_ios_pro_motion_enabled(bool value_arg); + + const int64_t* android_api_level() const; + void set_android_api_level(const int64_t* value_arg); + void set_android_api_level(int64_t value_arg); + + const bool* is_low_power_mode() const; + void set_is_low_power_mode(const bool* value_arg); + void set_is_low_power_mode(bool value_arg); + + const int64_t* thermal_state_index() const; + void set_thermal_state_index(const int64_t* value_arg); + void set_thermal_state_index(int64_t value_arg); + + const bool* has_adaptive_refresh_rate() const; + void set_has_adaptive_refresh_rate(const bool* value_arg); + void set_has_adaptive_refresh_rate(bool value_arg); + + const std::string* display_server() const; + void set_display_server(const std::string_view* value_arg); + void set_display_server(std::string_view value_arg); + + const int64_t* monitor_count() const; + void set_monitor_count(const int64_t* value_arg); + void set_monitor_count(int64_t value_arg); + + + private: + static DisplayInfoMessage FromEncodableList(const flutter::EncodableList& list); + flutter::EncodableList ToEncodableList() const; + friend class RefreshRateHostApi; + friend class RefreshRateFlutterApi; + friend class PigeonInternalCodecSerializer; + std::optional current_rate_; + std::optional max_rate_; + std::optional min_rate_; + std::optional supported_rates_; + std::optional is_variable_refresh_rate_; + std::optional engine_target_rate_; + std::optional ios_pro_motion_enabled_; + std::optional android_api_level_; + std::optional is_low_power_mode_; + std::optional thermal_state_index_; + std::optional has_adaptive_refresh_rate_; + std::optional display_server_; + std::optional monitor_count_; + +}; + + +class PigeonInternalCodecSerializer : public flutter::StandardCodecSerializer { + public: + PigeonInternalCodecSerializer(); + inline static PigeonInternalCodecSerializer& GetInstance() { + static PigeonInternalCodecSerializer sInstance; + return sInstance; + } + + void WriteValue( + const flutter::EncodableValue& value, + flutter::ByteStreamWriter* stream) const override; + + protected: + flutter::EncodableValue ReadValueOfType( + uint8_t type, + flutter::ByteStreamReader* stream) const override; + +}; + +// Generated interface from Pigeon that represents a handler of messages from Flutter. +class RefreshRateHostApi { + public: + RefreshRateHostApi(const RefreshRateHostApi&) = delete; + RefreshRateHostApi& operator=(const RefreshRateHostApi&) = delete; + virtual ~RefreshRateHostApi() {} + virtual ErrorOr GetDisplayInfo() = 0; + virtual std::optional Enable() = 0; + virtual std::optional Disable() = 0; + virtual std::optional PreferMax() = 0; + virtual std::optional PreferDefault() = 0; + virtual std::optional MatchContent(double fps) = 0; + virtual std::optional Boost(int64_t duration_ms) = 0; + virtual std::optional SetCategory(int64_t category_index) = 0; + virtual std::optional SetTouchBoost(bool enabled) = 0; + virtual ErrorOr IsSupported() = 0; + + // The codec used by RefreshRateHostApi. + static const flutter::StandardMessageCodec& GetCodec(); + // Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. + static void SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api); + static void SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api, + const std::string& message_channel_suffix); + static flutter::EncodableValue WrapError(std::string_view error_message); + static flutter::EncodableValue WrapError(const FlutterError& error); + + protected: + RefreshRateHostApi() = default; + +}; +// Generated class from Pigeon that represents Flutter messages that can be called from C++. +class RefreshRateFlutterApi { + public: + RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger); + RefreshRateFlutterApi( + flutter::BinaryMessenger* binary_messenger, + const std::string& message_channel_suffix); + static const flutter::StandardMessageCodec& GetCodec(); + void OnDisplayInfoChanged( + const DisplayInfoMessage& info, + std::function&& on_success, + std::function&& on_error); + + private: + flutter::BinaryMessenger* binary_messenger_; + std::string message_channel_suffix_; +}; + +} // namespace refresh_rate +#endif // PIGEON_REFRESH_RATE_API_G_H_ diff --git a/third_party/refresh_rate/linux/refresh_rate_plugin.cc b/third_party/refresh_rate/linux/refresh_rate_plugin.cc new file mode 100644 index 00000000..ca63eb64 --- /dev/null +++ b/third_party/refresh_rate/linux/refresh_rate_plugin.cc @@ -0,0 +1,274 @@ +// Linux implementation of RefreshRatePlugin. +// +// Uses GDK for query support. Implements the pigeon protocol using raw binary +// channel handlers — avoids the GObject/C++ bridge problem with BinaryMessenger. +// Control is not supported on Linux; compositor owns refresh rates. + +#include "include/refresh_rate/refresh_rate_plugin.h" + +#include +#include +#include +#include +#include + +#define REFRESH_RATE_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), refresh_rate_plugin_get_type(), RefreshRatePlugin)) + +#define PIGEON_CHANNEL_PREFIX "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi." + +struct _RefreshRatePlugin { + GObject parent_instance; + FlBinaryMessenger* messenger; +}; + +G_DEFINE_TYPE(RefreshRatePlugin, refresh_rate_plugin, g_object_get_type()) + +// ─── Pigeon binary encoding helpers ───────────────────────────────── + +static void pb_byte(GByteArray* b, guint8 v) { + g_byte_array_append(b, &v, 1); +} + +static void pb_varint(GByteArray* b, gsize v) { + if (v < 254) { + pb_byte(b, (guint8)v); + } else if (v < 65536) { + pb_byte(b, 254); + guint16 u = (guint16)v; + g_byte_array_append(b, (const guint8*)&u, 2); + } else { + pb_byte(b, 255); + guint32 u = (guint32)v; + g_byte_array_append(b, (const guint8*)&u, 4); + } +} + +static void pb_null(GByteArray* b) { pb_byte(b, 0); } +static void pb_bool(GByteArray* b, gboolean v) { pb_byte(b, v ? 1 : 2); } + +static void pb_double(GByteArray* b, double v) { + pb_byte(b, 6); + g_byte_array_append(b, (const guint8*)&v, 8); +} + +static void pb_int64(GByteArray* b, gint64 v) { + pb_byte(b, 4); + g_byte_array_append(b, (const guint8*)&v, 8); +} + +static void pb_string(GByteArray* b, const char* s) { + pb_byte(b, 7); + gsize len = strlen(s); + pb_varint(b, len); + g_byte_array_append(b, (const guint8*)s, len); +} + +// Build pigeon success response: outer list [result] +// For display info: result is custom type 129 (DisplayInfoMessage) +static GBytes* build_display_info_response( + double current, double max_rate, double min_rate, + double* rates, int n_rates, + gboolean is_vrr, + const char* display_server, + gint64 monitor_count) { + + GByteArray* b = g_byte_array_new(); + + // Outer success wrapper: list([result]) + pb_byte(b, 12); pb_varint(b, 1); + + // Custom type 129 (DisplayInfoMessage) + pb_byte(b, 129); + + // Inner list of 13 fields + pb_byte(b, 12); pb_varint(b, 13); + + pb_double(b, current); // 0: currentRate + pb_double(b, max_rate); // 1: maxRate + pb_double(b, min_rate); // 2: minRate + + // 3: supportedRates list + pb_byte(b, 12); pb_varint(b, n_rates); + for (int i = 0; i < n_rates; i++) pb_double(b, rates[i]); + + pb_bool(b, is_vrr); // 4: isVariableRefreshRate + pb_double(b, 60.0); // 5: engineTargetRate + pb_null(b); // 6: iosProMotionEnabled + pb_null(b); // 7: androidApiLevel + pb_null(b); // 8: isLowPowerMode + pb_null(b); // 9: thermalStateIndex + pb_bool(b, is_vrr); // 10: hasAdaptiveRefreshRate + + if (display_server) pb_string(b, display_server); + else pb_null(b); // 11: displayServer + + pb_int64(b, monitor_count); // 12: monitorCount + + return g_byte_array_free_to_bytes(b); +} + +// Pigeon empty success response: [] +static GBytes* build_empty_success() { + GByteArray* b = g_byte_array_new(); + pb_byte(b, 12); pb_varint(b, 0); + return g_byte_array_free_to_bytes(b); +} + +// Pigeon bool success response: [bool] +static GBytes* build_bool_success(gboolean v) { + GByteArray* b = g_byte_array_new(); + pb_byte(b, 12); pb_varint(b, 1); + pb_bool(b, v); + return g_byte_array_free_to_bytes(b); +} + +// ─── GDK display helpers ──────────────────────────────────────────── + +static GdkMonitor* get_primary_monitor() { + GdkDisplay* display = gdk_display_get_default(); + if (!display) return nullptr; + GdkMonitor* monitor = gdk_display_get_primary_monitor(display); + if (!monitor) monitor = gdk_display_get_monitor(display, 0); + return monitor; +} + +static double get_monitor_rate(GdkMonitor* monitor) { + if (!monitor) return 60.0; + int rate_mhz = gdk_monitor_get_refresh_rate(monitor); + return rate_mhz > 0 ? rate_mhz / 1000.0 : 60.0; +} + +static double get_primary_refresh_rate() { + return get_monitor_rate(get_primary_monitor()); +} + +static double get_max_refresh_rate() { + GdkDisplay* display = gdk_display_get_default(); + if (!display) return 60.0; + double maxRate = 0.0; + int n = gdk_display_get_n_monitors(display); + for (int i = 0; i < n; i++) { + double rate = get_monitor_rate(gdk_display_get_monitor(display, i)); + if (rate > maxRate) maxRate = rate; + } + return maxRate > 0 ? maxRate : 60.0; +} + +static const char* get_display_server_type() { + GdkDisplay* display = gdk_display_get_default(); + if (!display) return "unknown"; + const gchar* name = G_OBJECT_TYPE_NAME(display); + if (name) { + if (g_str_has_prefix(name, "GdkWayland")) return "wayland"; + if (g_str_has_prefix(name, "GdkX11")) return "x11"; + } + const char* wayland = g_getenv("WAYLAND_DISPLAY"); + if (wayland && wayland[0] != '\0') return "wayland"; + const char* x11 = g_getenv("DISPLAY"); + if (x11 && x11[0] != '\0') return "x11"; + return "unknown"; +} + +// ─── Pigeon channel handlers ──────────────────────────────────────── + +static void handle_get_display_info( + FlBinaryMessenger* messenger, + const gchar* channel, + GBytes* message, + FlBinaryMessengerResponseHandle* response_handle, + gpointer user_data) { + + double current = get_primary_refresh_rate(); + double max_r = get_max_refresh_rate(); + double min_r = current; // GDK doesn't expose min; use current + + // Collect unique rates + GdkDisplay* display = gdk_display_get_default(); + std::set seen; + if (display) { + int n = gdk_display_get_n_monitors(display); + for (int i = 0; i < n; i++) { + int rate_mhz = gdk_monitor_get_refresh_rate(gdk_display_get_monitor(display, i)); + if (rate_mhz > 0) seen.insert((rate_mhz + 500) / 1000); + } + } + if (seen.empty()) seen.insert((int)current); + + double rates[16]; + int n_rates = 0; + for (int r : seen) { + rates[n_rates++] = (double)r; + if (n_rates >= 16) break; + } + min_r = n_rates > 0 ? rates[0] : current; + + gboolean is_vrr = (max_r > current + 5.0); + const char* display_server = get_display_server_type(); + gint64 monitor_count = display ? gdk_display_get_n_monitors(display) : 1; + + g_autoptr(GBytes) response = build_display_info_response( + current, max_r, min_r, rates, n_rates, is_vrr, display_server, monitor_count); + fl_binary_messenger_send_response(messenger, response_handle, response, nullptr); +} + +static void handle_noop( + FlBinaryMessenger* messenger, + const gchar* channel, + GBytes* message, + FlBinaryMessengerResponseHandle* response_handle, + gpointer user_data) { + g_autoptr(GBytes) response = build_empty_success(); + fl_binary_messenger_send_response(messenger, response_handle, response, nullptr); +} + +static void handle_is_supported( + FlBinaryMessenger* messenger, + const gchar* channel, + GBytes* message, + FlBinaryMessengerResponseHandle* response_handle, + gpointer user_data) { + g_autoptr(GBytes) response = build_bool_success(FALSE); + fl_binary_messenger_send_response(messenger, response_handle, response, nullptr); +} + +// ─── Plugin lifecycle ──────────────────────────────────────────────── + +static void refresh_rate_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(refresh_rate_plugin_parent_class)->dispose(object); +} + +static void refresh_rate_plugin_class_init(RefreshRatePluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = refresh_rate_plugin_dispose; +} + +static void refresh_rate_plugin_init(RefreshRatePlugin* self) {} + +void refresh_rate_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + RefreshRatePlugin* plugin = REFRESH_RATE_PLUGIN( + g_object_new(refresh_rate_plugin_get_type(), nullptr)); + + FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); + plugin->messenger = messenger; + + fl_binary_messenger_set_message_handler_on_channel( + messenger, PIGEON_CHANNEL_PREFIX "getDisplayInfo", + handle_get_display_info, g_object_ref(plugin), g_object_unref); + + const char* noop_channels[] = { + "enable", "disable", "preferMax", "preferDefault", + "matchContent", "boost", "setCategory", "setTouchBoost", nullptr + }; + for (int i = 0; noop_channels[i]; i++) { + gchar* name = g_strconcat(PIGEON_CHANNEL_PREFIX, noop_channels[i], nullptr); + fl_binary_messenger_set_message_handler_on_channel( + messenger, name, handle_noop, g_object_ref(plugin), g_object_unref); + g_free(name); + } + + fl_binary_messenger_set_message_handler_on_channel( + messenger, PIGEON_CHANNEL_PREFIX "isSupported", + handle_is_supported, g_object_ref(plugin), g_object_unref); + + g_object_unref(plugin); +} diff --git a/third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift b/third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift new file mode 100644 index 00000000..630b2c92 --- /dev/null +++ b/third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift @@ -0,0 +1,167 @@ +import Cocoa +import FlutterMacOS +import CoreVideo +import QuartzCore + +public class RefreshRatePlugin: NSObject, FlutterPlugin, RefreshRateHostApi { + + private var flutterApi: RefreshRateFlutterApi? + private var _displayLinkRef: AnyObject? // CADisplayLink on macOS 14+ + private var lastReportedRate: Double = 0 + private var displayReconfigRegistered = false + + @available(macOS 14.0, *) + private var displayLink: CADisplayLink? { + get { _displayLinkRef as? CADisplayLink } + set { _displayLinkRef = newValue } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = RefreshRatePlugin() + RefreshRateHostApiSetup.setUp(binaryMessenger: registrar.messenger, api: instance) + instance.flutterApi = RefreshRateFlutterApi(binaryMessenger: registrar.messenger) + instance.startMonitoring() + } + + // MARK: - RefreshRateHostApi + + func getDisplayInfo() throws -> DisplayInfoMessage { + let current = getCurrentRate() + let max = getMaxRate() + let rates = getSupportedRates() + let min = rates.min() ?? 60.0 + return DisplayInfoMessage( + currentRate: current, maxRate: max, minRate: min, + supportedRates: rates, isVariableRefreshRate: detectVRR(), + engineTargetRate: 60.0, + iosProMotionEnabled: nil, androidApiLevel: nil, + isLowPowerMode: nil, thermalStateIndex: nil, + hasAdaptiveRefreshRate: detectVRR(), + displayServer: nil, monitorCount: Int64(NSScreen.screens.count)) + } + + func enable() throws { + guard #available(macOS 14.0, *), getMaxRate() > 60 else { return } + let max = getMaxRate() + setupDisplayLink(minimum: 60.0, maximum: max, preferred: max) + } + + func disable() throws { + if #available(macOS 14.0, *) { displayLink?.invalidate(); displayLink = nil } + } + + func preferMax() throws { try enable() } + func preferDefault() throws { try enable() } + + func matchContent(fps: Double) throws { + guard #available(macOS 14.0, *) else { return } + let max = getMaxRate() + let multiple = Swift.max(1.0, (max / fps).rounded(.down)) + setupDisplayLink(minimum: fps, maximum: fps * multiple, preferred: fps * multiple) + } + + func boost(durationMs: Int64) throws { + guard #available(macOS 14.0, *) else { return } + let max = getMaxRate() + setupDisplayLink(minimum: Swift.max(60.0, max * 0.66), maximum: max, preferred: max) + DispatchQueue.main.asyncAfter(deadline: .now() + Double(durationMs) / 1000.0) { + try? self.enable() + } + } + + func setCategory(categoryIndex: Int64) throws { + switch categoryIndex { + case 3: try? enable() + case 0, 1: try? disable() + default: break + } + } + + func setTouchBoost(enabled: Bool) throws {} + + func isSupported() throws -> Bool { + if #available(macOS 14.0, *) { return getMaxRate() > 60 } + return false + } + + // MARK: - Private helpers + + @available(macOS 14.0, *) + private func setupDisplayLink(minimum: Double, maximum: Double, preferred: Double) { + displayLink?.invalidate() + guard let screen = NSScreen.main else { return } + let link = screen.displayLink(target: self, selector: #selector(displayLinkFired)) + link.preferredFrameRateRange = CAFrameRateRange( + minimum: Float(minimum), maximum: Float(maximum), preferred: Float(preferred)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + @available(macOS 14.0, *) + @objc private func displayLinkFired(_ link: CADisplayLink) { + let rate = link.duration > 0 ? round(1.0 / link.duration) : 60.0 + if abs(rate - lastReportedRate) > 5.0 { + lastReportedRate = rate + let info = (try? getDisplayInfo()) ?? DisplayInfoMessage( + currentRate: rate, maxRate: rate, minRate: 60.0, + supportedRates: [60.0, rate], isVariableRefreshRate: rate > 60, + engineTargetRate: rate, iosProMotionEnabled: nil, + androidApiLevel: nil, isLowPowerMode: nil, + thermalStateIndex: nil, hasAdaptiveRefreshRate: nil, + displayServer: nil, monitorCount: Int64(NSScreen.screens.count)) + flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + } + + private func startMonitoring() { + guard !displayReconfigRegistered else { return } + CGDisplayRegisterReconfigurationCallback({ _, flags, userInfo in + guard let plugin = userInfo.flatMap({ + Unmanaged.fromOpaque($0).takeUnretainedValue() as RefreshRatePlugin? + }) else { return } + if flags.contains(.setModeFlag) { + guard let info = try? plugin.getDisplayInfo() else { return } + plugin.flutterApi?.onDisplayInfoChanged(info: info) { _ in } + } + }, Unmanaged.passUnretained(self).toOpaque()) + displayReconfigRegistered = true + if #available(macOS 14.0, *), displayLink == nil { + guard let screen = NSScreen.main else { return } + let link = screen.displayLink(target: self, selector: #selector(displayLinkFired)) + link.add(to: .main, forMode: .common) + displayLink = link + } + } + + private func getCurrentRate() -> Double { + if #available(macOS 14.0, *), let link = displayLink, link.duration > 0 { return round(1.0 / link.duration) } + if #available(macOS 12.0, *) { return Double(NSScreen.main?.maximumFramesPerSecond ?? 60) } + return getRefreshRateFromCGDisplay() + } + + private func getMaxRate() -> Double { + if #available(macOS 12.0, *) { return Double(NSScreen.main?.maximumFramesPerSecond ?? 60) } + return getSupportedRates().max() ?? 60.0 + } + + private func getSupportedRates() -> [Double] { + let id = (NSScreen.main?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) ?? CGMainDisplayID() + guard let modes = CGDisplayCopyAllDisplayModes(id, nil) as? [CGDisplayMode] else { return [60.0] } + let rates = Set(modes.compactMap { $0.refreshRate > 0 ? round($0.refreshRate) : nil }) + return rates.isEmpty ? [60.0] : Array(rates).sorted() + } + + private func detectVRR() -> Bool { + if #available(macOS 12.0, *) { + guard let s = NSScreen.main else { return false } + return s.minimumRefreshInterval != s.maximumRefreshInterval + } + return getMaxRate() > 60 + } + + private func getRefreshRateFromCGDisplay() -> Double { + let id = CGMainDisplayID() + guard let mode = CGDisplayCopyDisplayMode(id) else { return 60.0 } + return mode.refreshRate > 0 ? mode.refreshRate : 60.0 + } +} diff --git a/third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift b/third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift new file mode 100644 index 00000000..70c230c8 --- /dev/null +++ b/third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift @@ -0,0 +1,369 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Generated class from Pigeon that represents data sent in messages. +struct DisplayInfoMessage { + var currentRate: Double? = nil + var maxRate: Double? = nil + var minRate: Double? = nil + var supportedRates: [Double?]? = nil + var isVariableRefreshRate: Bool? = nil + var engineTargetRate: Double? = nil + var iosProMotionEnabled: Bool? = nil + var androidApiLevel: Int64? = nil + var isLowPowerMode: Bool? = nil + var thermalStateIndex: Int64? = nil + var hasAdaptiveRefreshRate: Bool? = nil + var displayServer: String? = nil + var monitorCount: Int64? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> DisplayInfoMessage? { + let currentRate: Double? = nilOrValue(pigeonVar_list[0]) + let maxRate: Double? = nilOrValue(pigeonVar_list[1]) + let minRate: Double? = nilOrValue(pigeonVar_list[2]) + let supportedRates: [Double?]? = nilOrValue(pigeonVar_list[3]) + let isVariableRefreshRate: Bool? = nilOrValue(pigeonVar_list[4]) + let engineTargetRate: Double? = nilOrValue(pigeonVar_list[5]) + let iosProMotionEnabled: Bool? = nilOrValue(pigeonVar_list[6]) + let androidApiLevel: Int64? = nilOrValue(pigeonVar_list[7]) + let isLowPowerMode: Bool? = nilOrValue(pigeonVar_list[8]) + let thermalStateIndex: Int64? = nilOrValue(pigeonVar_list[9]) + let hasAdaptiveRefreshRate: Bool? = nilOrValue(pigeonVar_list[10]) + let displayServer: String? = nilOrValue(pigeonVar_list[11]) + let monitorCount: Int64? = nilOrValue(pigeonVar_list[12]) + + return DisplayInfoMessage( + currentRate: currentRate, + maxRate: maxRate, + minRate: minRate, + supportedRates: supportedRates, + isVariableRefreshRate: isVariableRefreshRate, + engineTargetRate: engineTargetRate, + iosProMotionEnabled: iosProMotionEnabled, + androidApiLevel: androidApiLevel, + isLowPowerMode: isLowPowerMode, + thermalStateIndex: thermalStateIndex, + hasAdaptiveRefreshRate: hasAdaptiveRefreshRate, + displayServer: displayServer, + monitorCount: monitorCount + ) + } + func toList() -> [Any?] { + return [ + currentRate, + maxRate, + minRate, + supportedRates, + isVariableRefreshRate, + engineTargetRate, + iosProMotionEnabled, + androidApiLevel, + isLowPowerMode, + thermalStateIndex, + hasAdaptiveRefreshRate, + displayServer, + monitorCount, + ] + } +} + +private class RefreshRateApiPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return DisplayInfoMessage.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class RefreshRateApiPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? DisplayInfoMessage { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class RefreshRateApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return RefreshRateApiPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return RefreshRateApiPigeonCodecWriter(data: data) + } +} + +class RefreshRateApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = RefreshRateApiPigeonCodec(readerWriter: RefreshRateApiPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol RefreshRateHostApi { + func getDisplayInfo() throws -> DisplayInfoMessage + func enable() throws + func disable() throws + func preferMax() throws + func preferDefault() throws + func matchContent(fps: Double) throws + func boost(durationMs: Int64) throws + func setCategory(categoryIndex: Int64) throws + func setTouchBoost(enabled: Bool) throws + func isSupported() throws -> Bool +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class RefreshRateHostApiSetup { + static var codec: FlutterStandardMessageCodec { RefreshRateApiPigeonCodec.shared } + /// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let getDisplayInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getDisplayInfoChannel.setMessageHandler { _, reply in + do { + let result = try api.getDisplayInfo() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getDisplayInfoChannel.setMessageHandler(nil) + } + let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableChannel.setMessageHandler { _, reply in + do { + try api.enable() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableChannel.setMessageHandler(nil) + } + let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disableChannel.setMessageHandler { _, reply in + do { + try api.disable() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + disableChannel.setMessageHandler(nil) + } + let preferMaxChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + preferMaxChannel.setMessageHandler { _, reply in + do { + try api.preferMax() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + preferMaxChannel.setMessageHandler(nil) + } + let preferDefaultChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + preferDefaultChannel.setMessageHandler { _, reply in + do { + try api.preferDefault() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + preferDefaultChannel.setMessageHandler(nil) + } + let matchContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + matchContentChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let fpsArg = args[0] as! Double + do { + try api.matchContent(fps: fpsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + matchContentChannel.setMessageHandler(nil) + } + let boostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + boostChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let durationMsArg = args[0] as! Int64 + do { + try api.boost(durationMs: durationMsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + boostChannel.setMessageHandler(nil) + } + let setCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setCategoryChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let categoryIndexArg = args[0] as! Int64 + do { + try api.setCategory(categoryIndex: categoryIndexArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setCategoryChannel.setMessageHandler(nil) + } + let setTouchBoostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setTouchBoostChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let enabledArg = args[0] as! Bool + do { + try api.setTouchBoost(enabled: enabledArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setTouchBoostChannel.setMessageHandler(nil) + } + let isSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isSupportedChannel.setMessageHandler { _, reply in + do { + let result = try api.isSupported() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + isSupportedChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol RefreshRateFlutterApiProtocol { + func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) +} +class RefreshRateFlutterApi: RefreshRateFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: RefreshRateApiPigeonCodec { + return RefreshRateApiPigeonCodec.shared + } + func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([infoArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(Void())) + } + } + } +} diff --git a/third_party/refresh_rate/macos/refresh_rate.podspec b/third_party/refresh_rate/macos/refresh_rate.podspec new file mode 100644 index 00000000..0bc79d14 --- /dev/null +++ b/third_party/refresh_rate/macos/refresh_rate.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'refresh_rate' + s.version = '1.0.2' + s.summary = 'Control display refresh rates in Flutter.' + s.description = <<-DESC +Cross-platform Flutter plugin to query and control display refresh rates. + DESC + s.homepage = 'https://qoder.in' + s.license = { :file => '../LICENSE' } + s.author = { 'Qoder' => 'dev@qoder.in' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '10.14' + s.swift_version = '5.0' +end diff --git a/third_party/refresh_rate/macos/refresh_rate/Package.swift b/third_party/refresh_rate/macos/refresh_rate/Package.swift new file mode 100644 index 00000000..b4ad3b46 --- /dev/null +++ b/third_party/refresh_rate/macos/refresh_rate/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// +// This Package.swift file is consumed by Swift Package Manager when the plugin +// is used via the Flutter SPM integration (flutter build / flutter run with +// Swift Package Manager enabled). It mirrors the sources and settings declared +// in refresh_rate.podspec. + +import PackageDescription + +let package = Package( + name: "refresh_rate", + platforms: [ + .macOS(.v10_14), + ], + products: [ + .library(name: "refresh-rate", targets: ["refresh_rate"]), + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + ], + targets: [ + .target( + name: "refresh_rate", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework"), + ], + path: "Classes", + publicHeadersPath: ".", + cSettings: [ + .headerSearchPath("Classes"), + ] + ), + ] +) diff --git a/third_party/refresh_rate/pigeons/copyright.txt b/third_party/refresh_rate/pigeons/copyright.txt new file mode 100644 index 00000000..f131242e --- /dev/null +++ b/third_party/refresh_rate/pigeons/copyright.txt @@ -0,0 +1,2 @@ +Copyright 2026 Qoder (qoder.in). All rights reserved. +Use of this source code is governed by a BSD-style license. diff --git a/third_party/refresh_rate/pigeons/refresh_rate_api.dart b/third_party/refresh_rate/pigeons/refresh_rate_api.dart new file mode 100644 index 00000000..725ec7b3 --- /dev/null +++ b/third_party/refresh_rate/pigeons/refresh_rate_api.dart @@ -0,0 +1,63 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/refresh_rate_api.g.dart', + dartOptions: DartOptions(), + kotlinOut: + 'android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt', + kotlinOptions: KotlinOptions(package: 'in.qoder.refresh_rate.generated'), + swiftOut: 'ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift', + swiftOptions: SwiftOptions(), + cppHeaderOut: 'windows/refresh_rate_api.g.h', + cppSourceOut: 'windows/refresh_rate_api.g.cpp', + cppOptions: CppOptions(namespace: 'refresh_rate'), + copyrightHeader: 'pigeons/copyright.txt', +)) + +// ─── Data Classes ───────────────────────────────────────────────── + +class DisplayInfoMessage { + double? currentRate; + double? maxRate; + double? minRate; + List? supportedRates; + bool? isVariableRefreshRate; + double? engineTargetRate; + bool? iosProMotionEnabled; + int? androidApiLevel; + bool? isLowPowerMode; + // 0=nominal, 1=fair, 2=serious, 3=critical, null=unknown + int? thermalStateIndex; + bool? hasAdaptiveRefreshRate; + String? displayServer; + int? monitorCount; +} + +// ─── Host API (platform → Dart calls these) ────────────────────── +// These are what the Dart side calls INTO the platform + +@HostApi() +abstract class RefreshRateHostApi { + DisplayInfoMessage getDisplayInfo(); + + // Control + void enable(); + void disable(); + void preferMax(); + void preferDefault(); + void matchContent(double fps); + void boost(int durationMs); + // 0=none, 1=low, 2=normal, 3=high + void setCategory(int categoryIndex); + void setTouchBoost(bool enabled); + + bool isSupported(); +} + +// ─── Flutter API (Dart → platform listens to these) ────────────── +// These are what the platform calls UP to Dart + +@FlutterApi() +abstract class RefreshRateFlutterApi { + void onDisplayInfoChanged(DisplayInfoMessage info); +} diff --git a/third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart b/third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart new file mode 100644 index 00000000..e6f0cda9 --- /dev/null +++ b/third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart @@ -0,0 +1,63 @@ +// Linux-specific pigeon schema. Regenerate with: +// dart run pigeon --input pigeons/refresh_rate_api_linux.dart \ +// --one_language \ +// --cpp_header_out linux/refresh_rate_api.g.h \ +// --cpp_source_out linux/refresh_rate_api.g.cc \ +// --cpp_namespace refresh_rate \ +// --copyright_header pigeons/copyright.txt +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + cppHeaderOut: 'linux/refresh_rate_api.g.h', + cppSourceOut: 'linux/refresh_rate_api.g.cc', + cppOptions: CppOptions(namespace: 'refresh_rate'), + copyrightHeader: 'pigeons/copyright.txt', +)) + +// ─── Data Classes ───────────────────────────────────────────────── + +class DisplayInfoMessage { + double? currentRate; + double? maxRate; + double? minRate; + List? supportedRates; + bool? isVariableRefreshRate; + double? engineTargetRate; + bool? iosProMotionEnabled; + int? androidApiLevel; + bool? isLowPowerMode; + // 0=nominal, 1=fair, 2=serious, 3=critical, null=unknown + int? thermalStateIndex; + bool? hasAdaptiveRefreshRate; + String? displayServer; + int? monitorCount; +} + +// ─── Host API (platform → Dart calls these) ────────────────────── +// These are what the Dart side calls INTO the platform + +@HostApi() +abstract class RefreshRateHostApi { + DisplayInfoMessage getDisplayInfo(); + + // Control + void enable(); + void disable(); + void preferMax(); + void preferDefault(); + void matchContent(double fps); + void boost(int durationMs); + // 0=none, 1=low, 2=normal, 3=high + void setCategory(int categoryIndex); + void setTouchBoost(bool enabled); + + bool isSupported(); +} + +// ─── Flutter API (Dart → platform listens to these) ────────────── +// These are what the platform calls UP to Dart + +@FlutterApi() +abstract class RefreshRateFlutterApi { + void onDisplayInfoChanged(DisplayInfoMessage info); +} diff --git a/third_party/refresh_rate/pubspec.yaml b/third_party/refresh_rate/pubspec.yaml new file mode 100644 index 00000000..95860623 --- /dev/null +++ b/third_party/refresh_rate/pubspec.yaml @@ -0,0 +1,61 @@ +name: refresh_rate +description: >- + Unlock 90/120/144Hz on Android, iOS & desktop. Query display rates, control + refresh, benchmark FPS with live debug overlays. Uses modern platform APIs. +version: 1.0.2 +homepage: https://qoder.in/resources/refresh_rate +repository: https://github.com/qoder-official/refresh_rate +issue_tracker: https://github.com/qoder-official/refresh_rate/issues +documentation: https://pub.dev/documentation/refresh_rate/latest/ + +topics: + - display + - performance + - animation + - debug + - ui + +screenshots: + - description: "Live FPS overlay showing 120 FPS with frame budget diagnostics" + path: screenshots/fps.png + - description: "Hz badge confirming 120Hz refresh rate on a real device" + path: screenshots/hz.png + +funding: + - https://github.com/sponsors/qoder-official + +environment: + sdk: ^3.5.0 + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + plugin_platform_interface: ^2.1.7 + web: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + pigeon: ^22.0.0 + +flutter: + plugin: + platforms: + android: + package: in.qoder.refresh_rate + pluginClass: RefreshRatePlugin + ios: + pluginClass: RefreshRatePlugin + linux: + pluginClass: RefreshRatePlugin + macos: + pluginClass: RefreshRatePlugin + windows: + pluginClass: RefreshRatePluginCApi + web: + pluginClass: RefreshRateWeb + fileName: refresh_rate_web.dart diff --git a/third_party/refresh_rate/screenshots/fps.png b/third_party/refresh_rate/screenshots/fps.png new file mode 100644 index 00000000..1658d85f Binary files /dev/null and b/third_party/refresh_rate/screenshots/fps.png differ diff --git a/third_party/refresh_rate/screenshots/hz.png b/third_party/refresh_rate/screenshots/hz.png new file mode 100644 index 00000000..261465ca Binary files /dev/null and b/third_party/refresh_rate/screenshots/hz.png differ diff --git a/third_party/refresh_rate/test/fps_tracker_test.dart b/third_party/refresh_rate/test/fps_tracker_test.dart new file mode 100644 index 00000000..fe4fa4dc --- /dev/null +++ b/third_party/refresh_rate/test/fps_tracker_test.dart @@ -0,0 +1,218 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:refresh_rate/src/verification/fps_tracker.dart'; +import 'package:refresh_rate/src/verification/refresh_rate_session.dart'; +import 'package:refresh_rate/src/verification/session_scorer.dart'; +import 'package:refresh_rate/src/models/display_info.dart'; +import 'package:refresh_rate/src/models/enums.dart'; +import 'package:refresh_rate/src/models/session_report.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FpsTracker', () { + test('starts with no samples', () { + final tracker = FpsTracker(); + expect(tracker.sampleCount, 0); + expect(tracker.avgFps, 0.0); + }); + + test('addTimings accumulates samples', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimings(10, 8333)); + expect(tracker.sampleCount, 10); + }); + + test('avgFps is reasonable for 120fps timings', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimings(120, 8333)); + // 119 intervals * 1e6 / (119 * 8333µs) ≈ 120fps + expect(tracker.avgFps, closeTo(120.0, 5.0)); + }); + + test('reset clears all samples', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimings(10, 8333)); + tracker.reset(); + expect(tracker.sampleCount, 0); + }); + + test('onePercentLow is less than or equal to avgFps', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimingSequence([ + ...List.filled(100, 8333), + ...List.filled(10, 33333), + ])); + expect(tracker.onePercentLowFps, lessThanOrEqualTo(tracker.avgFps)); + }); + + test('jankyFrameCount counts frames over budget', () { + final tracker = FpsTracker(); + // At 60fps, budget = ~16667µs. Add 5 frames over budget. + tracker.addTimings(_fakeTimingSequence([ + ...List.filled(10, 8333), + ...List.filled(5, 20000), + ])); + expect(tracker.jankyFrameCount(60.0), 5); + }); + + test('missedFramePercent is 0 when no jank', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimings(100, 8333)); + expect(tracker.missedFramePercent(120.0), 0.0); + }); + + test('onePercentLowFps works for small sample count', () { + final tracker = FpsTracker(); + // 10 frames: 9 fast + 1 slow. With floor cutoff, 1 slow frame is captured. + tracker.addTimings(_fakeTimingSequence([ + ...List.filled(9, 8333), + ...List.filled(1, 33333), + ])); + // onePercentLow should be close to 30fps (the slow frame), not avgFps + expect(tracker.onePercentLowFps, lessThan(tracker.avgFps)); + }); + }); + + group('SessionScorer.compute', () { + test('returns inconclusive for zero-frame session', () { + final report = SessionScorer.compute( + sessionName: 'empty', + tracker: FpsTracker(), + targetHz: 120.0, + validDuration: Duration(seconds: 1), + excludedDuration: Duration.zero, + exclusionReasons: {}, + deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), + ); + expect(report.verdict, Verdict.inconclusive); + }); + + test('returns excellent for smooth 120fps session', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimings(120, 8333)); + final report = SessionScorer.compute( + sessionName: 'smooth', + tracker: tracker, + targetHz: 120.0, + validDuration: Duration(seconds: 1), + excludedDuration: Duration.zero, + exclusionReasons: {}, + deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), + ); + expect(report.verdict, Verdict.excellent); + }); + + test('returns powerLimited bottleneck when LPM is true', () { + final tracker = FpsTracker(); + tracker.addTimings(_fakeTimings(60, 8333)); + final report = SessionScorer.compute( + sessionName: 'lpm', + tracker: tracker, + targetHz: 120.0, + validDuration: Duration(seconds: 1), + excludedDuration: Duration.zero, + exclusionReasons: {}, + deviceState: DeviceStateSnapshot( + thermalState: ThermalState.nominal, + isLowPowerMode: true, + ), + ); + expect(report.likelyBottleneck, Bottleneck.powerLimited); + }); + + test('returns displayCapped for 88% fps', () { + final tracker = FpsTracker(); + // 88% of 120fps ≈ 105.6fps. Frame interval: 1_000_000 / 105.6 ≈ 9470µs + tracker.addTimings(_fakeTimings(100, 9470)); + final report = SessionScorer.compute( + sessionName: 'capped', + tracker: tracker, + targetHz: 120.0, + validDuration: Duration(seconds: 1), + excludedDuration: Duration.zero, + exclusionReasons: {}, + deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), + ); + expect(report.likelyBottleneck, Bottleneck.displayCapped); + }); + }); + + group('RefreshRateSession', () { + test('starts in running state', () { + WidgetsFlutterBinding.ensureInitialized(); + final info = DisplayInfo( + currentRate: 120.0, maxRate: 120.0, minRate: 60.0, + supportedRates: [60.0, 120.0], isVariableRefreshRate: true, + engineTargetRate: 120.0, thermalState: ThermalState.nominal, + ); + final session = RefreshRateSession.create('test', info); + expect(session.state, SessionState.running); + expect(session.name, 'test'); + }); + + test('end() returns a SessionReport', () async { + WidgetsFlutterBinding.ensureInitialized(); + final info = DisplayInfo( + currentRate: 120.0, maxRate: 120.0, minRate: 60.0, + supportedRates: [60.0, 120.0], isVariableRefreshRate: true, + engineTargetRate: 120.0, thermalState: ThermalState.nominal, + ); + final session = RefreshRateSession.create('scroll_test', info); + final report = await session.end(); + expect(report.sessionName, 'scroll_test'); + expect(report.targetHz, 120.0); + expect(report.verdict, isA()); + }); + }); +} + +/// Builds a list of fake timings with proper sequential vsync timestamps. +/// Each frame's vsync = sum of all preceding frame durations. +List _fakeTimingSequence(List durationsUs) { + int vsync = 0; + return durationsUs.map((dur) { + final t = _MockFrameTiming(dur, vsync); + vsync += dur; + return t as FrameTiming; + }).toList(); +} + +List _fakeTimings(int count, int intervalUs) => + _fakeTimingSequence(List.filled(count, intervalUs)); + + + +class _MockFrameTiming implements FrameTiming { + final int _totalUs; + final int _vsyncUs; + _MockFrameTiming(this._totalUs, this._vsyncUs); + + @override + Duration get buildDuration => Duration(microseconds: (_totalUs * 0.6).toInt()); + @override + Duration get rasterDuration => Duration(microseconds: (_totalUs * 0.4).toInt()); + @override + Duration get totalSpan => Duration(microseconds: _totalUs); + @override + int get frameNumber => 0; + @override + int get layerCacheCount => 0; + @override + int get layerCacheBytes => 0; + @override + int get pictureCacheCount => 0; + @override + int get pictureCacheBytes => 0; + @override + int timestampInMicroseconds(FramePhase phase) { + if (phase == FramePhase.vsyncStart) return _vsyncUs; + return 0; + } + + // Forward any additional SDK-version-specific members gracefully. + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/third_party/refresh_rate/test/models_test.dart b/third_party/refresh_rate/test/models_test.dart new file mode 100644 index 00000000..3901a575 --- /dev/null +++ b/third_party/refresh_rate/test/models_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:refresh_rate/src/models/enums.dart'; +import 'package:refresh_rate/src/models/display_info.dart'; +import 'package:refresh_rate/src/models/session_report.dart'; +import 'package:refresh_rate/src/generated/refresh_rate_api.g.dart'; + +void main() { + group('DisplayInfo.fromMessage', () { + test('maps all fields correctly', () { + final msg = DisplayInfoMessage( + currentRate: 120.0, + maxRate: 120.0, + minRate: 60.0, + supportedRates: [60.0, 120.0], + isVariableRefreshRate: true, + engineTargetRate: 120.0, + iosProMotionEnabled: true, + androidApiLevel: null, + isLowPowerMode: false, + thermalStateIndex: 0, + hasAdaptiveRefreshRate: true, + displayServer: null, + monitorCount: null, + ); + final info = DisplayInfo.fromMessage(msg); + expect(info.currentRate, 120.0); + expect(info.thermalState, ThermalState.nominal); + expect(info.isVariableRefreshRate, true); + }); + + test('handles null thermalStateIndex as unknown', () { + final msg = DisplayInfoMessage( + currentRate: 60.0, maxRate: 60.0, minRate: 60.0, + supportedRates: [60.0], isVariableRefreshRate: false, + engineTargetRate: 60.0, + thermalStateIndex: null, + ); + final info = DisplayInfo.fromMessage(msg); + expect(info.thermalState, ThermalState.unknown); + }); + }); + + group('ThermalState', () { + test('fromIndex maps correctly', () { + expect(ThermalState.fromIndex(0), ThermalState.nominal); + expect(ThermalState.fromIndex(1), ThermalState.fair); + expect(ThermalState.fromIndex(2), ThermalState.serious); + expect(ThermalState.fromIndex(3), ThermalState.critical); + expect(ThermalState.fromIndex(null), ThermalState.unknown); + }); + }); + + group('SessionReport.toCsv', () { + test('escapes commas in sessionName', () { + final report = SessionReport( + sessionName: 'feed, scroll', + verdict: Verdict.good, + likelyBottleneck: Bottleneck.none, + targetHz: 120.0, + observedAvgHz: 118.0, + frameBudgetMs: 8.33, + avgFps: 118.0, + onePercentLowFps: 90.0, + fivePercentLowFps: 100.0, + avgBuildMs: 3.0, + avgRasterMs: 4.0, + avgTotalFrameMs: 7.0, + jankyFrameCount: 2, + severeJankCount: 0, + missedFramePercent: 1.5, + validDuration: Duration(seconds: 5), + excludedDuration: Duration.zero, + exclusionReasons: {}, + deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), + ); + final csv = report.toCsv(); + final lines = csv.split('\n'); + expect(lines.length, 2); + // The escaped session name should appear in the values row + expect(lines[1], contains('"feed, scroll"')); + }); + }); +} diff --git a/third_party/refresh_rate/test/refresh_rate_api_test.dart b/third_party/refresh_rate/test/refresh_rate_api_test.dart new file mode 100644 index 00000000..a6155aa0 --- /dev/null +++ b/third_party/refresh_rate/test/refresh_rate_api_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:refresh_rate/refresh_rate.dart'; +import 'package:refresh_rate/src/generated/refresh_rate_api.g.dart'; +import 'package:refresh_rate/src/refresh_rate_api_adapter.dart'; + +class _FakeHostApi implements RefreshRateApiAdapter { + final calls = []; + @override + DisplayInfoMessage getDisplayInfo() => DisplayInfoMessage( + currentRate: 120.0, maxRate: 120.0, minRate: 60.0, + supportedRates: [60.0, 120.0], isVariableRefreshRate: true, + engineTargetRate: 120.0, thermalStateIndex: 0, + ); + + @override void enable() { calls.add('enable'); } + @override void disable() { calls.add('disable'); } + @override void preferMax() { calls.add('preferMax'); } + @override void preferDefault() { calls.add('preferDefault'); } + @override void matchContent(double fps) { calls.add('matchContent:$fps'); } + @override void boost(int durationMs) { calls.add('boost:$durationMs'); } + @override void setCategory(int c) { calls.add('setCategory:$c'); } + @override void setTouchBoost(bool e) { calls.add('setTouchBoost:$e'); } + @override bool isSupported() => true; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeHostApi fakeApi; + + setUp(() { + fakeApi = _FakeHostApi(); + RefreshRate.setApiForTesting(fakeApi); + }); + + tearDown(() => RefreshRate.clearApiForTesting()); + + test('enable() calls platform enable', () async { + RefreshRate.enable(); + expect(fakeApi.calls, contains('enable')); + }); + + test('preferMax() calls platform preferMax', () { + RefreshRate.preferMax(); + expect(fakeApi.calls, contains('preferMax')); + }); + + test('matchContent passes fps to platform', () { + RefreshRate.matchContent(24.0); + expect(fakeApi.calls, contains('matchContent:24.0')); + }); + + test('category(high) calls setCategory(3)', () { + RefreshRate.category(RateCategory.high); + expect(fakeApi.calls, contains('setCategory:3')); + }); + + test('info returns fallback before first fetch', () { + expect(RefreshRate.info.currentRate, isA()); + }); +} diff --git a/third_party/refresh_rate/windows/CMakeLists.txt b/third_party/refresh_rate/windows/CMakeLists.txt new file mode 100644 index 00000000..cd093e07 --- /dev/null +++ b/third_party/refresh_rate/windows/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "refresh_rate") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "refresh_rate_plugin") + +add_library(${PLUGIN_NAME} SHARED + "refresh_rate_plugin_c_api.cpp" + "refresh_rate_api.g.cpp" +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) diff --git a/third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h b/third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h new file mode 100644 index 00000000..a4145a41 --- /dev/null +++ b/third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void RefreshRatePluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} +#endif + +#endif // FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_C_API_H_ diff --git a/third_party/refresh_rate/windows/refresh_rate_api.g.cpp b/third_party/refresh_rate/windows/refresh_rate_api.g.cpp new file mode 100644 index 00000000..f4911e8a --- /dev/null +++ b/third_party/refresh_rate/windows/refresh_rate_api.g.cpp @@ -0,0 +1,651 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#undef _HAS_EXCEPTIONS + +#include "refresh_rate_api.g.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace refresh_rate { +using flutter::BasicMessageChannel; +using flutter::CustomEncodableValue; +using flutter::EncodableList; +using flutter::EncodableMap; +using flutter::EncodableValue; + +FlutterError CreateConnectionError(const std::string channel_name) { + return FlutterError( + "channel-error", + "Unable to establish connection on channel: '" + channel_name + "'.", + EncodableValue("")); +} + +// DisplayInfoMessage + +DisplayInfoMessage::DisplayInfoMessage() {} + +DisplayInfoMessage::DisplayInfoMessage( + const double* current_rate, + const double* max_rate, + const double* min_rate, + const EncodableList* supported_rates, + const bool* is_variable_refresh_rate, + const double* engine_target_rate, + const bool* ios_pro_motion_enabled, + const int64_t* android_api_level, + const bool* is_low_power_mode, + const int64_t* thermal_state_index, + const bool* has_adaptive_refresh_rate, + const std::string* display_server, + const int64_t* monitor_count) + : current_rate_(current_rate ? std::optional(*current_rate) : std::nullopt), + max_rate_(max_rate ? std::optional(*max_rate) : std::nullopt), + min_rate_(min_rate ? std::optional(*min_rate) : std::nullopt), + supported_rates_(supported_rates ? std::optional(*supported_rates) : std::nullopt), + is_variable_refresh_rate_(is_variable_refresh_rate ? std::optional(*is_variable_refresh_rate) : std::nullopt), + engine_target_rate_(engine_target_rate ? std::optional(*engine_target_rate) : std::nullopt), + ios_pro_motion_enabled_(ios_pro_motion_enabled ? std::optional(*ios_pro_motion_enabled) : std::nullopt), + android_api_level_(android_api_level ? std::optional(*android_api_level) : std::nullopt), + is_low_power_mode_(is_low_power_mode ? std::optional(*is_low_power_mode) : std::nullopt), + thermal_state_index_(thermal_state_index ? std::optional(*thermal_state_index) : std::nullopt), + has_adaptive_refresh_rate_(has_adaptive_refresh_rate ? std::optional(*has_adaptive_refresh_rate) : std::nullopt), + display_server_(display_server ? std::optional(*display_server) : std::nullopt), + monitor_count_(monitor_count ? std::optional(*monitor_count) : std::nullopt) {} + +const double* DisplayInfoMessage::current_rate() const { + return current_rate_ ? &(*current_rate_) : nullptr; +} + +void DisplayInfoMessage::set_current_rate(const double* value_arg) { + current_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_current_rate(double value_arg) { + current_rate_ = value_arg; +} + + +const double* DisplayInfoMessage::max_rate() const { + return max_rate_ ? &(*max_rate_) : nullptr; +} + +void DisplayInfoMessage::set_max_rate(const double* value_arg) { + max_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_max_rate(double value_arg) { + max_rate_ = value_arg; +} + + +const double* DisplayInfoMessage::min_rate() const { + return min_rate_ ? &(*min_rate_) : nullptr; +} + +void DisplayInfoMessage::set_min_rate(const double* value_arg) { + min_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_min_rate(double value_arg) { + min_rate_ = value_arg; +} + + +const EncodableList* DisplayInfoMessage::supported_rates() const { + return supported_rates_ ? &(*supported_rates_) : nullptr; +} + +void DisplayInfoMessage::set_supported_rates(const EncodableList* value_arg) { + supported_rates_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_supported_rates(const EncodableList& value_arg) { + supported_rates_ = value_arg; +} + + +const bool* DisplayInfoMessage::is_variable_refresh_rate() const { + return is_variable_refresh_rate_ ? &(*is_variable_refresh_rate_) : nullptr; +} + +void DisplayInfoMessage::set_is_variable_refresh_rate(const bool* value_arg) { + is_variable_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_is_variable_refresh_rate(bool value_arg) { + is_variable_refresh_rate_ = value_arg; +} + + +const double* DisplayInfoMessage::engine_target_rate() const { + return engine_target_rate_ ? &(*engine_target_rate_) : nullptr; +} + +void DisplayInfoMessage::set_engine_target_rate(const double* value_arg) { + engine_target_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_engine_target_rate(double value_arg) { + engine_target_rate_ = value_arg; +} + + +const bool* DisplayInfoMessage::ios_pro_motion_enabled() const { + return ios_pro_motion_enabled_ ? &(*ios_pro_motion_enabled_) : nullptr; +} + +void DisplayInfoMessage::set_ios_pro_motion_enabled(const bool* value_arg) { + ios_pro_motion_enabled_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_ios_pro_motion_enabled(bool value_arg) { + ios_pro_motion_enabled_ = value_arg; +} + + +const int64_t* DisplayInfoMessage::android_api_level() const { + return android_api_level_ ? &(*android_api_level_) : nullptr; +} + +void DisplayInfoMessage::set_android_api_level(const int64_t* value_arg) { + android_api_level_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_android_api_level(int64_t value_arg) { + android_api_level_ = value_arg; +} + + +const bool* DisplayInfoMessage::is_low_power_mode() const { + return is_low_power_mode_ ? &(*is_low_power_mode_) : nullptr; +} + +void DisplayInfoMessage::set_is_low_power_mode(const bool* value_arg) { + is_low_power_mode_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_is_low_power_mode(bool value_arg) { + is_low_power_mode_ = value_arg; +} + + +const int64_t* DisplayInfoMessage::thermal_state_index() const { + return thermal_state_index_ ? &(*thermal_state_index_) : nullptr; +} + +void DisplayInfoMessage::set_thermal_state_index(const int64_t* value_arg) { + thermal_state_index_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_thermal_state_index(int64_t value_arg) { + thermal_state_index_ = value_arg; +} + + +const bool* DisplayInfoMessage::has_adaptive_refresh_rate() const { + return has_adaptive_refresh_rate_ ? &(*has_adaptive_refresh_rate_) : nullptr; +} + +void DisplayInfoMessage::set_has_adaptive_refresh_rate(const bool* value_arg) { + has_adaptive_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_has_adaptive_refresh_rate(bool value_arg) { + has_adaptive_refresh_rate_ = value_arg; +} + + +const std::string* DisplayInfoMessage::display_server() const { + return display_server_ ? &(*display_server_) : nullptr; +} + +void DisplayInfoMessage::set_display_server(const std::string_view* value_arg) { + display_server_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_display_server(std::string_view value_arg) { + display_server_ = value_arg; +} + + +const int64_t* DisplayInfoMessage::monitor_count() const { + return monitor_count_ ? &(*monitor_count_) : nullptr; +} + +void DisplayInfoMessage::set_monitor_count(const int64_t* value_arg) { + monitor_count_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void DisplayInfoMessage::set_monitor_count(int64_t value_arg) { + monitor_count_ = value_arg; +} + + +EncodableList DisplayInfoMessage::ToEncodableList() const { + EncodableList list; + list.reserve(13); + list.push_back(current_rate_ ? EncodableValue(*current_rate_) : EncodableValue()); + list.push_back(max_rate_ ? EncodableValue(*max_rate_) : EncodableValue()); + list.push_back(min_rate_ ? EncodableValue(*min_rate_) : EncodableValue()); + list.push_back(supported_rates_ ? EncodableValue(*supported_rates_) : EncodableValue()); + list.push_back(is_variable_refresh_rate_ ? EncodableValue(*is_variable_refresh_rate_) : EncodableValue()); + list.push_back(engine_target_rate_ ? EncodableValue(*engine_target_rate_) : EncodableValue()); + list.push_back(ios_pro_motion_enabled_ ? EncodableValue(*ios_pro_motion_enabled_) : EncodableValue()); + list.push_back(android_api_level_ ? EncodableValue(*android_api_level_) : EncodableValue()); + list.push_back(is_low_power_mode_ ? EncodableValue(*is_low_power_mode_) : EncodableValue()); + list.push_back(thermal_state_index_ ? EncodableValue(*thermal_state_index_) : EncodableValue()); + list.push_back(has_adaptive_refresh_rate_ ? EncodableValue(*has_adaptive_refresh_rate_) : EncodableValue()); + list.push_back(display_server_ ? EncodableValue(*display_server_) : EncodableValue()); + list.push_back(monitor_count_ ? EncodableValue(*monitor_count_) : EncodableValue()); + return list; +} + +DisplayInfoMessage DisplayInfoMessage::FromEncodableList(const EncodableList& list) { + DisplayInfoMessage decoded; + auto& encodable_current_rate = list[0]; + if (!encodable_current_rate.IsNull()) { + decoded.set_current_rate(std::get(encodable_current_rate)); + } + auto& encodable_max_rate = list[1]; + if (!encodable_max_rate.IsNull()) { + decoded.set_max_rate(std::get(encodable_max_rate)); + } + auto& encodable_min_rate = list[2]; + if (!encodable_min_rate.IsNull()) { + decoded.set_min_rate(std::get(encodable_min_rate)); + } + auto& encodable_supported_rates = list[3]; + if (!encodable_supported_rates.IsNull()) { + decoded.set_supported_rates(std::get(encodable_supported_rates)); + } + auto& encodable_is_variable_refresh_rate = list[4]; + if (!encodable_is_variable_refresh_rate.IsNull()) { + decoded.set_is_variable_refresh_rate(std::get(encodable_is_variable_refresh_rate)); + } + auto& encodable_engine_target_rate = list[5]; + if (!encodable_engine_target_rate.IsNull()) { + decoded.set_engine_target_rate(std::get(encodable_engine_target_rate)); + } + auto& encodable_ios_pro_motion_enabled = list[6]; + if (!encodable_ios_pro_motion_enabled.IsNull()) { + decoded.set_ios_pro_motion_enabled(std::get(encodable_ios_pro_motion_enabled)); + } + auto& encodable_android_api_level = list[7]; + if (!encodable_android_api_level.IsNull()) { + decoded.set_android_api_level(std::get(encodable_android_api_level)); + } + auto& encodable_is_low_power_mode = list[8]; + if (!encodable_is_low_power_mode.IsNull()) { + decoded.set_is_low_power_mode(std::get(encodable_is_low_power_mode)); + } + auto& encodable_thermal_state_index = list[9]; + if (!encodable_thermal_state_index.IsNull()) { + decoded.set_thermal_state_index(std::get(encodable_thermal_state_index)); + } + auto& encodable_has_adaptive_refresh_rate = list[10]; + if (!encodable_has_adaptive_refresh_rate.IsNull()) { + decoded.set_has_adaptive_refresh_rate(std::get(encodable_has_adaptive_refresh_rate)); + } + auto& encodable_display_server = list[11]; + if (!encodable_display_server.IsNull()) { + decoded.set_display_server(std::get(encodable_display_server)); + } + auto& encodable_monitor_count = list[12]; + if (!encodable_monitor_count.IsNull()) { + decoded.set_monitor_count(std::get(encodable_monitor_count)); + } + return decoded; +} + + +PigeonInternalCodecSerializer::PigeonInternalCodecSerializer() {} + +EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( + uint8_t type, + flutter::ByteStreamReader* stream) const { + switch (type) { + case 129: { + return CustomEncodableValue(DisplayInfoMessage::FromEncodableList(std::get(ReadValue(stream)))); + } + default: + return flutter::StandardCodecSerializer::ReadValueOfType(type, stream); + } +} + +void PigeonInternalCodecSerializer::WriteValue( + const EncodableValue& value, + flutter::ByteStreamWriter* stream) const { + if (const CustomEncodableValue* custom_value = std::get_if(&value)) { + if (custom_value->type() == typeid(DisplayInfoMessage)) { + stream->WriteByte(129); + WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); + return; + } + } + flutter::StandardCodecSerializer::WriteValue(value, stream); +} + +/// The codec used by RefreshRateHostApi. +const flutter::StandardMessageCodec& RefreshRateHostApi::GetCodec() { + return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); +} + +// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. +void RefreshRateHostApi::SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api) { + RefreshRateHostApi::SetUp(binary_messenger, api, ""); +} + +void RefreshRateHostApi::SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api, + const std::string& message_channel_suffix) { + const std::string prepended_suffix = message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : ""; + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + ErrorOr output = api->GetDisplayInfo(); + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->Enable(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->Disable(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->PreferMax(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + std::optional output = api->PreferDefault(); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_fps_arg = args.at(0); + if (encodable_fps_arg.IsNull()) { + reply(WrapError("fps_arg unexpectedly null.")); + return; + } + const auto& fps_arg = std::get(encodable_fps_arg); + std::optional output = api->MatchContent(fps_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_duration_ms_arg = args.at(0); + if (encodable_duration_ms_arg.IsNull()) { + reply(WrapError("duration_ms_arg unexpectedly null.")); + return; + } + const int64_t duration_ms_arg = encodable_duration_ms_arg.LongValue(); + std::optional output = api->Boost(duration_ms_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_category_index_arg = args.at(0); + if (encodable_category_index_arg.IsNull()) { + reply(WrapError("category_index_arg unexpectedly null.")); + return; + } + const int64_t category_index_arg = encodable_category_index_arg.LongValue(); + std::optional output = api->SetCategory(category_index_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_enabled_arg = args.at(0); + if (encodable_enabled_arg.IsNull()) { + reply(WrapError("enabled_arg unexpectedly null.")); + return; + } + const auto& enabled_arg = std::get(encodable_enabled_arg); + std::optional output = api->SetTouchBoost(enabled_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + ErrorOr output = api->IsSupported(); + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } +} + +EncodableValue RefreshRateHostApi::WrapError(std::string_view error_message) { + return EncodableValue(EncodableList{ + EncodableValue(std::string(error_message)), + EncodableValue("Error"), + EncodableValue() + }); +} + +EncodableValue RefreshRateHostApi::WrapError(const FlutterError& error) { + return EncodableValue(EncodableList{ + EncodableValue(error.code()), + EncodableValue(error.message()), + error.details() + }); +} + +// Generated class from Pigeon that represents Flutter messages that can be called from C++. +RefreshRateFlutterApi::RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger) + : binary_messenger_(binary_messenger), + message_channel_suffix_("") {} + +RefreshRateFlutterApi::RefreshRateFlutterApi( + flutter::BinaryMessenger* binary_messenger, + const std::string& message_channel_suffix) + : binary_messenger_(binary_messenger), + message_channel_suffix_(message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : "") {} + +const flutter::StandardMessageCodec& RefreshRateFlutterApi::GetCodec() { + return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); +} + +void RefreshRateFlutterApi::OnDisplayInfoChanged( + const DisplayInfoMessage& info_arg, + std::function&& on_success, + std::function&& on_error) { + const std::string channel_name = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged" + message_channel_suffix_; + BasicMessageChannel<> channel(binary_messenger_, channel_name, &GetCodec()); + EncodableValue encoded_api_arguments = EncodableValue(EncodableList{ + CustomEncodableValue(info_arg), + }); + channel.Send(encoded_api_arguments, [channel_name, on_success = std::move(on_success), on_error = std::move(on_error)](const uint8_t* reply, size_t reply_size) { + std::unique_ptr response = GetCodec().DecodeMessage(reply, reply_size); + const auto& encodable_return_value = *response; + const auto* list_return_value = std::get_if(&encodable_return_value); + if (list_return_value) { + if (list_return_value->size() > 1) { + on_error(FlutterError(std::get(list_return_value->at(0)), std::get(list_return_value->at(1)), list_return_value->at(2))); + } else { + on_success(); + } + } else { + on_error(CreateConnectionError(channel_name)); + } + }); +} + +} // namespace refresh_rate diff --git a/third_party/refresh_rate/windows/refresh_rate_api.g.h b/third_party/refresh_rate/windows/refresh_rate_api.g.h new file mode 100644 index 00000000..70ef60a0 --- /dev/null +++ b/third_party/refresh_rate/windows/refresh_rate_api.g.h @@ -0,0 +1,233 @@ +// Copyright 2026 Qoder (qoder.in). All rights reserved. +// Use of this source code is governed by a BSD-style license. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#ifndef PIGEON_REFRESH_RATE_API_G_H_ +#define PIGEON_REFRESH_RATE_API_G_H_ +#include +#include +#include +#include + +#include +#include +#include + +namespace refresh_rate { + + +// Generated class from Pigeon. + +class FlutterError { + public: + explicit FlutterError(const std::string& code) + : code_(code) {} + explicit FlutterError(const std::string& code, const std::string& message) + : code_(code), message_(message) {} + explicit FlutterError(const std::string& code, const std::string& message, const flutter::EncodableValue& details) + : code_(code), message_(message), details_(details) {} + + const std::string& code() const { return code_; } + const std::string& message() const { return message_; } + const flutter::EncodableValue& details() const { return details_; } + + private: + std::string code_; + std::string message_; + flutter::EncodableValue details_; +}; + +template class ErrorOr { + public: + ErrorOr(const T& rhs) : v_(rhs) {} + ErrorOr(const T&& rhs) : v_(std::move(rhs)) {} + ErrorOr(const FlutterError& rhs) : v_(rhs) {} + ErrorOr(const FlutterError&& rhs) : v_(std::move(rhs)) {} + + bool has_error() const { return std::holds_alternative(v_); } + const T& value() const { return std::get(v_); }; + const FlutterError& error() const { return std::get(v_); }; + + private: + friend class RefreshRateHostApi; + friend class RefreshRateFlutterApi; + ErrorOr() = default; + T TakeValue() && { return std::get(std::move(v_)); } + + std::variant v_; +}; + + + +// Generated class from Pigeon that represents data sent in messages. +class DisplayInfoMessage { + public: + // Constructs an object setting all non-nullable fields. + DisplayInfoMessage(); + + // Constructs an object setting all fields. + explicit DisplayInfoMessage( + const double* current_rate, + const double* max_rate, + const double* min_rate, + const flutter::EncodableList* supported_rates, + const bool* is_variable_refresh_rate, + const double* engine_target_rate, + const bool* ios_pro_motion_enabled, + const int64_t* android_api_level, + const bool* is_low_power_mode, + const int64_t* thermal_state_index, + const bool* has_adaptive_refresh_rate, + const std::string* display_server, + const int64_t* monitor_count); + + const double* current_rate() const; + void set_current_rate(const double* value_arg); + void set_current_rate(double value_arg); + + const double* max_rate() const; + void set_max_rate(const double* value_arg); + void set_max_rate(double value_arg); + + const double* min_rate() const; + void set_min_rate(const double* value_arg); + void set_min_rate(double value_arg); + + const flutter::EncodableList* supported_rates() const; + void set_supported_rates(const flutter::EncodableList* value_arg); + void set_supported_rates(const flutter::EncodableList& value_arg); + + const bool* is_variable_refresh_rate() const; + void set_is_variable_refresh_rate(const bool* value_arg); + void set_is_variable_refresh_rate(bool value_arg); + + const double* engine_target_rate() const; + void set_engine_target_rate(const double* value_arg); + void set_engine_target_rate(double value_arg); + + const bool* ios_pro_motion_enabled() const; + void set_ios_pro_motion_enabled(const bool* value_arg); + void set_ios_pro_motion_enabled(bool value_arg); + + const int64_t* android_api_level() const; + void set_android_api_level(const int64_t* value_arg); + void set_android_api_level(int64_t value_arg); + + const bool* is_low_power_mode() const; + void set_is_low_power_mode(const bool* value_arg); + void set_is_low_power_mode(bool value_arg); + + const int64_t* thermal_state_index() const; + void set_thermal_state_index(const int64_t* value_arg); + void set_thermal_state_index(int64_t value_arg); + + const bool* has_adaptive_refresh_rate() const; + void set_has_adaptive_refresh_rate(const bool* value_arg); + void set_has_adaptive_refresh_rate(bool value_arg); + + const std::string* display_server() const; + void set_display_server(const std::string_view* value_arg); + void set_display_server(std::string_view value_arg); + + const int64_t* monitor_count() const; + void set_monitor_count(const int64_t* value_arg); + void set_monitor_count(int64_t value_arg); + + + private: + static DisplayInfoMessage FromEncodableList(const flutter::EncodableList& list); + flutter::EncodableList ToEncodableList() const; + friend class RefreshRateHostApi; + friend class RefreshRateFlutterApi; + friend class PigeonInternalCodecSerializer; + std::optional current_rate_; + std::optional max_rate_; + std::optional min_rate_; + std::optional supported_rates_; + std::optional is_variable_refresh_rate_; + std::optional engine_target_rate_; + std::optional ios_pro_motion_enabled_; + std::optional android_api_level_; + std::optional is_low_power_mode_; + std::optional thermal_state_index_; + std::optional has_adaptive_refresh_rate_; + std::optional display_server_; + std::optional monitor_count_; + +}; + + +class PigeonInternalCodecSerializer : public flutter::StandardCodecSerializer { + public: + PigeonInternalCodecSerializer(); + inline static PigeonInternalCodecSerializer& GetInstance() { + static PigeonInternalCodecSerializer sInstance; + return sInstance; + } + + void WriteValue( + const flutter::EncodableValue& value, + flutter::ByteStreamWriter* stream) const override; + + protected: + flutter::EncodableValue ReadValueOfType( + uint8_t type, + flutter::ByteStreamReader* stream) const override; + +}; + +// Generated interface from Pigeon that represents a handler of messages from Flutter. +class RefreshRateHostApi { + public: + RefreshRateHostApi(const RefreshRateHostApi&) = delete; + RefreshRateHostApi& operator=(const RefreshRateHostApi&) = delete; + virtual ~RefreshRateHostApi() {} + virtual ErrorOr GetDisplayInfo() = 0; + virtual std::optional Enable() = 0; + virtual std::optional Disable() = 0; + virtual std::optional PreferMax() = 0; + virtual std::optional PreferDefault() = 0; + virtual std::optional MatchContent(double fps) = 0; + virtual std::optional Boost(int64_t duration_ms) = 0; + virtual std::optional SetCategory(int64_t category_index) = 0; + virtual std::optional SetTouchBoost(bool enabled) = 0; + virtual ErrorOr IsSupported() = 0; + + // The codec used by RefreshRateHostApi. + static const flutter::StandardMessageCodec& GetCodec(); + // Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. + static void SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api); + static void SetUp( + flutter::BinaryMessenger* binary_messenger, + RefreshRateHostApi* api, + const std::string& message_channel_suffix); + static flutter::EncodableValue WrapError(std::string_view error_message); + static flutter::EncodableValue WrapError(const FlutterError& error); + + protected: + RefreshRateHostApi() = default; + +}; +// Generated class from Pigeon that represents Flutter messages that can be called from C++. +class RefreshRateFlutterApi { + public: + RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger); + RefreshRateFlutterApi( + flutter::BinaryMessenger* binary_messenger, + const std::string& message_channel_suffix); + static const flutter::StandardMessageCodec& GetCodec(); + void OnDisplayInfoChanged( + const DisplayInfoMessage& info, + std::function&& on_success, + std::function&& on_error); + + private: + flutter::BinaryMessenger* binary_messenger_; + std::string message_channel_suffix_; +}; + +} // namespace refresh_rate +#endif // PIGEON_REFRESH_RATE_API_G_H_ diff --git a/third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp b/third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp new file mode 100644 index 00000000..8ea0cc3b --- /dev/null +++ b/third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp @@ -0,0 +1,155 @@ +// Windows implementation of RefreshRatePlugin. +// +// Uses QueryDisplayConfig for accurate refresh rates (rational numbers like 59.94Hz) +// and EnumDisplaySettings for enumerating all supported modes. +// Control is query-only on Windows — rate control requires system-level access. + +#include "include/refresh_rate/refresh_rate_plugin_c_api.h" +#include "refresh_rate_api.g.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace refresh_rate { + +class RefreshRatePlugin : public flutter::Plugin, public RefreshRateHostApi { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + RefreshRatePlugin(); + virtual ~RefreshRatePlugin(); + + // RefreshRateHostApi + ErrorOr GetDisplayInfo() override; + std::optional Enable() override { return std::nullopt; } + std::optional Disable() override { return std::nullopt; } + std::optional PreferMax() override { return std::nullopt; } + std::optional PreferDefault() override { return std::nullopt; } + std::optional MatchContent(double fps) override { return std::nullopt; } + std::optional Boost(int64_t duration_ms) override { return std::nullopt; } + std::optional SetCategory(int64_t category_index) override { return std::nullopt; } + std::optional SetTouchBoost(bool enabled) override { return std::nullopt; } + ErrorOr IsSupported() override { return false; } + + private: + double GetCurrentRate(); + double GetMaxRate(); + std::vector GetSupportedRates(); + double GetCurrentRateViaQueryDisplayConfig(); + double GetCurrentRateViaEnumDisplaySettings(); +}; + +void RefreshRatePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto plugin = std::make_unique(); + RefreshRateHostApi::SetUp(registrar->messenger(), plugin.get()); + registrar->AddPlugin(std::move(plugin)); +} + +RefreshRatePlugin::RefreshRatePlugin() {} +RefreshRatePlugin::~RefreshRatePlugin() {} + +double RefreshRatePlugin::GetCurrentRateViaQueryDisplayConfig() { + UINT32 pathCount = 0, modeCount = 0; + if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) { + return 0.0; + } + std::vector paths(pathCount); + std::vector modes(modeCount); + if (QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths.data(), + &modeCount, modes.data(), nullptr) != ERROR_SUCCESS) { + return 0.0; + } + for (UINT32 i = 0; i < modeCount; i++) { + if (modes[i].infoType == DISPLAYCONFIG_MODE_INFO_TYPE_TARGET) { + auto vsync = modes[i].targetMode.targetVideoSignalInfo.vSyncFreq; + if (vsync.Denominator > 0) { + return static_cast(vsync.Numerator) / static_cast(vsync.Denominator); + } + } + } + return 0.0; +} + +double RefreshRatePlugin::GetCurrentRateViaEnumDisplaySettings() { + DEVMODE dm; + dm.dmSize = sizeof(dm); + if (EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &dm)) { + double rate = static_cast(dm.dmDisplayFrequency); + if (rate > 1) return rate; + } + return 60.0; +} + +double RefreshRatePlugin::GetCurrentRate() { + double rate = GetCurrentRateViaQueryDisplayConfig(); + if (rate > 1.0) return rate; + return GetCurrentRateViaEnumDisplaySettings(); +} + +double RefreshRatePlugin::GetMaxRate() { + auto rates = GetSupportedRates(); + if (rates.empty()) return GetCurrentRate(); + return *std::max_element(rates.begin(), rates.end()); +} + +std::vector RefreshRatePlugin::GetSupportedRates() { + DEVMODE dm, current; + dm.dmSize = sizeof(dm); + current.dmSize = sizeof(current); + EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, ¤t); + + std::set rateSet; + int modeNum = 0; + while (EnumDisplaySettings(NULL, modeNum, &dm)) { + if (dm.dmPelsWidth == current.dmPelsWidth && + dm.dmPelsHeight == current.dmPelsHeight && + dm.dmDisplayFrequency > 1) { + rateSet.insert(static_cast(dm.dmDisplayFrequency)); + } + modeNum++; + } + if (rateSet.empty()) rateSet.insert(GetCurrentRate()); + return std::vector(rateSet.begin(), rateSet.end()); +} + +ErrorOr RefreshRatePlugin::GetDisplayInfo() { + double currentRate = GetCurrentRate(); + auto supportedRates = GetSupportedRates(); + double maxRate = supportedRates.empty() ? currentRate + : *std::max_element(supportedRates.begin(), supportedRates.end()); + double minRate = supportedRates.empty() ? currentRate : supportedRates.front(); + bool isVRR = (maxRate - minRate > 30) && supportedRates.size() <= 4; + + flutter::EncodableList rates; + for (double r : supportedRates) { + rates.push_back(flutter::EncodableValue(r)); + } + + DisplayInfoMessage msg; + msg.set_current_rate(currentRate); + msg.set_max_rate(maxRate); + msg.set_min_rate(minRate); + msg.set_supported_rates(rates); + msg.set_is_variable_refresh_rate(isVRR); + msg.set_engine_target_rate(60.0); + return msg; +} + +} // namespace refresh_rate + +void RefreshRatePluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + refresh_rate::RefreshRatePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 72592476..eab8f5b5 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); IrondashEngineContextPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); + RefreshRatePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RefreshRatePluginCApi")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 69caccba..bf263147 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows irondash_engine_context + refresh_rate super_native_extensions )