win提交

This commit is contained in:
Developer
2026-06-22 03:50:59 +08:00
parent 8786abd59e
commit f7520b17b2
32 changed files with 2179 additions and 1431 deletions

View File

@@ -2,7 +2,89 @@
所有重要变更均记录于此文件。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
> 保留最近 10 个版本v6.89.1 ~ v6.94.5。更早版本v6.87.0 ~ v6.89.0)的特性已合并进软件特性功能文档,详见各版本条目。
> 保留最近 10 个版本v6.89.1 ~ v6.96.0。更早版本v6.87.0 ~ v6.89.0)的特性已合并进软件特性功能文档,详见各版本条目。
***
## [v6.96.0] - 2026-06-22
### ✨ 新增Windows 桌面端)
#### 窗口大小预设菜单3×2 网格)
- **功能**: 点击窗口标题栏"口"按钮(最小化与关闭之间),在按钮下方弹出 3 列 × 2 行的窗口大小预设网格菜单
- **预设尺寸**: 小窗 800×600 / 标准 1024×768 / 宽屏 1280×720 / 大屏 1440×900 / 全高清 1920×1080 / 最大化·还原
- **交互**:
- 弹出位置自动右对齐按钮,带边界自适应(防止溢出屏幕)
- 入场动画:缩放 + 淡入从右上角展开180ms easeOutCubic
- 退出动画:缩放 + 淡出120ms
- 点击外部区域自动关闭
- 每个单元格悬停时高亮accent 色 14% 背景 + 45% 边框)
- **设计**: iOS 26 Liquid Glass 风格 — Alert 层级毛玻璃blur 40px、圆角 16px、GlassTokens 边框 + 阴影
- **技术方案**:
- 提取共享组件 `shared/widgets/window_size_popup.dart`,供 `DesktopWindowTitleBar``AdaptiveNavBar` 复用
- 使用 `OverlayEntry` + `Positioned` 实现精确定位弹出
- `_WindowsControlButton.onPressed` / `_WindowControlBtn.onPressed` 签名改为 `void Function(BuildContext)` 以传递按钮自身 context 用于定位
- **文件**:
- lib/shared/widgets/window_size_popup.dart新增
- lib/features/desktop/desktop_window_title_bar.dart"口"按钮改为弹出菜单)
- lib/app/layout/adaptive_nav_bar.dart引用共享组件删除本地重复定义
***
## [v6.95.0] - 2026-06-21
### 🐛 修复(鸿蒙端专项)
#### 鸿蒙端横屏翻转失效
- **Issue: 鸿蒙端横屏时软件没有翻转,安卓/iOS 正常**
- **根因**`EntryAbility.ets` 注册了 `flutter/platform` 通道拦截器,其 `default` 分支调用 `result.notImplemented()`,导致 `SystemChrome.setPreferredOrientations` 等平台方法全部失效
- **修复**:移除 `flutter/platform` 通道拦截器,由 Flutter 鸿蒙引擎原生 `PlatformChannel.ets` 处理所有平台方法
- **文件**ohos/entry/src/main/ets/entryability/EntryAbility.ets
#### 鸿蒙端复制粘贴不生效
- **Issue: 点击复制按钮显示成功但实际未复制;输入框长按粘贴无反应**
- **根因1复制**:同上,`flutter/platform` 拦截器覆盖了引擎原生 `Clipboard.setData` 实现
- **根因2粘贴**`READ_PASTEBOARD``system_basic` 级系统权限,普通应用无法通过 `requestPermissionsFromUser` 申请;引擎原生 `getClipboardData` 因权限缺失返回 `null`
- **修复**
- 复制:移除拦截器后由引擎原生 `PlatformChannel.ets` 处理 `Clipboard.setData`(无需权限)
- 粘贴:创建 `CustomPlatformPlugin.ets` 中间件,继承 `PlatformPlugin``PlatformPluginCallback`,覆盖 `getClipboardData` 方法,使用系统安全控件 `PasteButton` 获取临时剪贴板读取授权(点击后授权持续到灭屏/切后台/退出)
- 覆盖 `clipboardHasStrings` 返回 `true`,确保输入框工具栏粘贴按钮显示
-`EntryAbility.ets` 覆盖 `providePlatformPlugin` 返回 `CustomPlatformPlugin`
- **架构亮点**:不修改 SDK 源码通过继承实现中间件过渡层SDK 升级不受影响
- **文件**ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets新增、ohos/entry/src/main/ets/entryability/EntryAbility.ets
#### 鸿蒙端设备信息显示"未知设备"
- **Issue: 我的设备页面鸿蒙端显示"未知设备",安卓/iOS 正常**
- **根因**`DeviceMethodHandler.ets` 返回鸿蒙原生字段名(`manufacture``marketName``productModel` 等),与 `AndroidDeviceInfo.fromMap` 期望的 Android 字段名(`manufacturer``name``model` 等)不匹配
- **修复**
-`DeviceMethodHandler.ets` 增加 Android 兼容字段映射,保留原生鸿蒙字段的同时补充 `board`/`bootloader`/`brand`/`device`/`display`/`fingerprint`/`hardware`/`host`/`id`/`manufacturer`/`model`/`product`/`name`/`tags`/`type`/`isPhysicalDevice` 等字段
-`AndroidDeviceInfo.fromMap` 增加防御性默认值(`?? ''``?? 0``?? true`),防止 `type 'Null' is not a subtype of type 'String'` 崩溃
- **文件**packages/device_info_plus/ohos/.../DeviceMethodHandler.ets、packages/device_info_plus/lib/src/model/android_device_info.dart
#### 鸿蒙端搜索页 flutter_keyboard_visibility 崩溃
- **Issue: 搜索页面报 `MissingPluginException(No implementation found for method listen on channel flutter_keyboard_visibility)`**
- **根因**`flutter_keyboard_visibility` 插件无鸿蒙原生实现,`EventChannel.listen` 抛出 `MissingPluginException`
- **修复**`KeyboardManager` 延迟初始化 `KeyboardVisibilityController` 并捕获异常,鸿蒙端降级为 `MediaQuery.of(context).viewInsets.bottom > 0` 检测键盘可见性
- **文件**lib/shared/widgets/adaptive/keyboard_safe_sheet.dart
#### 鸿蒙端安装失败READ_PASTEBOARD 权限)
- **Issue: 声明 `ohos.permission.READ_PASTEBOARD` 后安装失败,报 `grant request permissions failed`**
- **根因**`READ_PASTEBOARD``system_basic` 级权限,普通应用无法通过 `module.json5``requestPermissions` 声明
- **修复**:从 `module.json5` 移除 `READ_PASTEBOARD` 权限声明,改用 `PasteButton` 安全控件获取临时授权
- **文件**ohos/entry/src/main/module.json5
#### 新特性轮播正则表达式崩溃
- **Issue: `FormatException: Range out of order in character class ^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]+)\s*`**
- **根因**Dart raw string (`r'...'`) 中 `\u{1F300}` 不会被解析为 Unicode 转义,导致正则引擎看到无效字符范围
- **修复**:改用 `Runes` 逐字符检测 code point 范围,避免正则 Unicode 转义兼容性问题
- **文件**lib/features/home/presentation/widgets/new_features_dialog.dart
### 🔧 架构改进
#### SDK 零修改原则
- **原则**:所有鸿蒙端修复均不修改 Flutter 鸿蒙 SDK 源码(`e:\sdk\flutter-ohos\`),通过项目内中间件继承实现
- **中间件**`CustomPlatformPlugin.ets` 继承 `PlatformPlugin` + `PlatformPluginCallback`,覆盖剪贴板方法
- **优势**SDK 升级时无需合并冲突,中间件代码完全在项目可控范围内
***

View File

@@ -1,13 +1,14 @@
/// ============================================================
/// 闲言APP — 自适应导航栏
/// 创建时间: 2026-05-29
/// 更新时间: 2026-06-12
/// 更新时间: 2026-06-22
/// 作用: 宽屏时垂直导航栏窄屏时底部导航栏支持4种停靠位置
/// 上次更新: 从 core/layout/ 迁移至 app/layout/,修复架构层级违规
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单OverlayEntry 定位弹出)
/// ============================================================
import 'dart:ui';
import 'package:badges/badges.dart' as badges;
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -20,6 +21,7 @@ import '../../core/utils/platform/platform_utils.dart' as pu;
import '../../features/discover/providers/chat_provider.dart';
import '../../features/settings/providers/theme_settings_provider.dart';
import '../../shared/widgets/animation/tab_icon_sprite.dart';
import '../../shared/widgets/window_size_popup.dart';
class AdaptiveNavBar extends ConsumerWidget {
const AdaptiveNavBar({
@@ -124,11 +126,15 @@ class AdaptiveNavBar extends ConsumerWidget {
label,
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
color: isSelected
? (ext.isDark ? ext.textInverse : ext.accent)
: (ext.isDark
? ext.textInverse.withValues(alpha: 0.38)
? ext.textInverse.withValues(
alpha: 0.38,
)
: const Color(0xFFAEAEB2)),
),
),
@@ -182,7 +188,11 @@ class AdaptiveNavBar extends ConsumerWidget {
}
/// 折叠/展开导航栏按钮
Widget _buildCollapseButton(WidgetRef ref, bool isCollapsed, AppThemeExtension ext) {
Widget _buildCollapseButton(
WidgetRef ref,
bool isCollapsed,
AppThemeExtension ext,
) {
return GestureDetector(
onTap: () => ref.read(splitViewProvider.notifier).toggleNavBarCollapsed(),
behavior: HitTestBehavior.opaque,
@@ -312,7 +322,8 @@ class AdaptiveNavBar extends ConsumerWidget {
buildTab(TabSpriteType.home, 0, '闲言'),
buildTab(TabSpriteType.discover, 1, '发现'),
buildTab(TabSpriteType.profile, 2, '我的'),
if (isTop && pu.isDesktop) _buildDesktopWindowControls(ext),
if (isTop && pu.isDesktop)
_buildDesktopWindowControls(context, ext),
],
),
),
@@ -321,40 +332,54 @@ class AdaptiveNavBar extends ConsumerWidget {
}
/// 桌面端窗口控制按钮(最小化/最大化/关闭)+ 可拖拽标题栏区域
Widget _buildDesktopWindowControls(AppThemeExtension ext) {
Widget _buildDesktopWindowControls(
BuildContext context,
AppThemeExtension ext,
) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onPanStart: (_) => windowManager.startDragging(),
child: const SizedBox(height: 52, width: 40),
),
// Windows: C++ HTTRANSPARENT 方案处理拖拽,无需 Dart 侧 startDragging
if (pu.isWindows)
const SizedBox(height: 52, width: 40)
else
GestureDetector(
onPanDown: (_) => windowManager.startDragging(),
onDoubleTap: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
child: const SizedBox(height: 52, width: 40),
),
_WindowControlBtn(
icon: CupertinoIcons.minus,
ext: ext,
onPressed: () => windowManager.minimize(),
onPressed: (_) => windowManager.minimize(),
),
_WindowControlBtn(
icon: CupertinoIcons.square,
ext: ext,
iconSize: 12,
onPressed: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
onPressed: (btnCtx) => _showWindowSizeMenu(btnCtx),
),
_WindowControlBtn(
icon: CupertinoIcons.xmark,
ext: ext,
onPressed: () => windowManager.close(),
onPressed: (_) => windowManager.close(),
isClose: true,
),
],
);
}
/// 窗口大小预设菜单
/// 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
void _showWindowSizeMenu(BuildContext buttonContext) {
WindowSizePopup.show(buttonContext);
}
}
/// 窗口控制按钮(最小化/最大化/关闭)
@@ -369,7 +394,9 @@ class _WindowControlBtn extends StatefulWidget {
final IconData icon;
final AppThemeExtension ext;
final VoidCallback onPressed;
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
final void Function(BuildContext buttonContext) onPressed;
final double iconSize;
final bool isClose;
@@ -393,7 +420,7 @@ class _WindowControlBtnState extends State<_WindowControlBtn> {
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector(
onTap: widget.onPressed,
onTap: () => widget.onPressed(context),
child: Container(
width: 46,
height: 52,

View File

@@ -9,6 +9,7 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import '../network/connectivity_service.dart';
import 'token_service.dart';
@@ -156,9 +157,9 @@ class TokenRefreshWatcher {
/// 导航到登录页
static void _navigateToLogin() {
try {
final nav = rootNavigatorKey.currentState;
if (nav != null) {
nav.pushNamed(AppRoutes.login);
final context = rootNavigatorKey.currentContext;
if (context != null && context.mounted) {
context.go(AppRoutes.login);
Log.i('TokenRefreshWatcher: 已导航到登录页');
}
} catch (e) {

View File

@@ -6,6 +6,8 @@
/// 上次更新: 初始创建,实现 TrayMenuCallbacks + TrayMenuLabels + DesktopTrayMenuBuilder
/// ============================================================
import 'dart:ui';
import 'package:flutter/foundation.dart';
import '../../../features/home/presentation/providers/readlater/readlater_entry.dart';
@@ -131,18 +133,41 @@ class TrayMenuLabels {
);
/// 根据语言 ID 获取标签
///
/// 当 languageId 为 'system' 时,自动解析系统语言。
/// 支持中文(简/繁)和英文,其他语言回退到中文。
factory TrayMenuLabels.forLanguage(String languageId) {
switch (languageId) {
final resolvedId = languageId == 'system'
? _resolveSystemLanguageId()
: languageId;
switch (resolvedId) {
case 'en':
return enUS;
case 'zh_CN':
case 'zh_TW':
case 'system':
default:
return zhCN;
}
}
/// 解析系统语言 ID
///
/// 从平台获取系统首选语言,映射到项目支持的语言 ID。
static String _resolveSystemLanguageId() {
try {
final locale = PlatformDispatcher.instance.locale;
final langCode = locale.languageCode;
// 英文系统
if (langCode == 'en') return 'en';
// 中文系统(简体/繁体)
if (langCode == 'zh') return 'zh_CN';
// 其他语言回退到中文
return 'zh_CN';
} catch (e) {
return 'zh_CN';
}
}
/// 格式化带未读数的 Tooltip
String formatTooltip(int unreadCount) {
if (unreadCount <= 0) return tooltip;
@@ -256,12 +281,7 @@ class DesktopTrayMenuBuilder {
required TrayMenuCallbacks callbacks,
}) {
if (entries.isEmpty) {
return [
TrayMenuItem(
label: labels.noRecentRead,
disabled: true,
),
];
return [TrayMenuItem(label: labels.noRecentRead, disabled: true)];
}
// 取前 maxRecentReadItems 条

View File

@@ -1,12 +1,13 @@
/// ============================================================
/// 闲言APP — tray_manager 系统托盘服务实现
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-19
/// 作用: 基于 tray_manager 实现跨平台系统托盘macOS/Win/Linux
/// 上次更新: 初始创建,实现 DesktopTrayService 接口
/// 上次更新: 修复 Windows 托盘图标路径问题,使用文件系统绝对路径替代 Flutter asset 路径
/// ============================================================
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:tray_manager/tray_manager.dart';
@@ -19,8 +20,7 @@ import 'package:xianyan/core/utils/logger.dart';
///
/// 支持 macOS / Windows / Linux 三端。
/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService]。
class TrayManagerTrayService
implements DesktopTrayService, TrayListener {
class TrayManagerTrayService implements DesktopTrayService, TrayListener {
TrayManagerTrayService._();
static final TrayManagerTrayService _instance = TrayManagerTrayService._();
@@ -72,27 +72,44 @@ class TrayManagerTrayService
Future<void> setIcon({required bool isDark}) async {
if (!_initialized) return;
try {
// macOS 使用 isTemplate 让系统自动反色
// Windows/Linux 需要明暗两套图标
final iconPath = isDark
? 'assets/images/tray_icon_dark.png'
: 'assets/images/tray_icon_light.png';
if (pu.isMacOS) {
// macOS: isTemplate=true 时系统自动处理深浅色
await trayManager.setIcon(
'assets/images/tray_icon_light.png',
isTemplate: true,
);
} else {
// Windows/Linux: 明暗两套图标
} else if (pu.isWindows) {
// Windows: tray_manager 仅支持 .ico 文件LoadImageW 不支持 PNG
// 使用 app_icon.ico已通过 CMakeLists.txt 安装到构建产物目录)
final iconPath = _resolveWindowsIcoPath();
await trayManager.setIcon(iconPath);
} else {
// Linux: 使用 Flutter asset 路径
final assetName = isDark
? 'assets/images/tray_icon_dark.png'
: 'assets/images/tray_icon_light.png';
await trayManager.setIcon(assetName);
}
} catch (e) {
Log.e('TrayManagerTrayService.setIcon 失败: $e');
}
}
/// 解析 Windows 平台的 .ico 托盘图标路径
///
/// tray_manager 在 Windows 上使用 LoadImageW 加载图标,
/// 仅支持 .ico 格式(不支持 .png
/// app_icon.ico 已通过 CMakeLists.txt 安装到构建产物目录。
static String _resolveWindowsIcoPath() {
try {
final exeDir = File(Platform.resolvedExecutable).parent.path;
return '$exeDir\\app_icon.ico';
} catch (e) {
Log.w('TrayManagerTrayService._resolveWindowsIcoPath 失败: $e');
return 'app_icon.ico';
}
}
@override
Future<void> setToolTip(String tip) async {
if (!_initialized) return;
@@ -108,13 +125,11 @@ class TrayManagerTrayService
if (!_initialized) return;
try {
// macOS: setTitle 显示数字角标(标题显示在图标旁)
// Windows/Linux: tray_manager 不原生支持角标,仅更新 Tooltip
if (pu.isMacOS) {
await trayManager.setTitle(count > 0 ? count.toString() : '');
}
// 统一更新 Tooltip 包含未读数
final tip = count > 0 ? '闲言 — $count 条未读' : '闲言';
await trayManager.setToolTip(tip);
// Tooltip 的多语言版本由 DesktopTrayController 统一管理,
// 此处不再硬编码中文 Tooltip避免覆盖多语言版本
} catch (e) {
Log.e('TrayManagerTrayService.setUnreadBadge 失败: $e');
}

View File

@@ -3,7 +3,7 @@
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-19
/// 作用: 基于 flutter_acrylic 实现 Windows 窗口特效Win11 Mica Alt/Mica/Win10 Acrylic
/// 上次更新: 新增 Mica Alt 特效支持Win11 build >= 22621自动降级 Mica Alt → Mica → Acrylic
/// 上次更新: 修复 setEffect 后 DwmExtendFrameIntoClientArea 导致 Flutter 视图布局异常
/// ============================================================
import 'dart:io';
@@ -11,6 +11,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:window_manager/window_manager.dart';
import '../desktop_window_effect_service.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
@@ -49,8 +50,10 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
_isWin11OrLater = await _detectWindows11OrLater();
_isMicaAltSupportedCache = await _isMicaAltSupported();
_initialized = true;
Log.i('WindowsAcrylicService 初始化完成 '
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)');
Log.i(
'WindowsAcrylicService 初始化完成 '
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)',
);
} catch (e) {
Log.e('WindowsAcrylicService.initialize 失败: $e');
}
@@ -74,15 +77,9 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
dark: isDark,
);
Log.i('WindowsAcrylicService 应用 Mica Alt 特效 (isDark=$isDark)');
return;
}
if (_isWin11OrLater == true) {
} else if (_isWin11OrLater == true) {
// Win11: Mica 背景(跟随系统主题)
await Window.setEffect(
effect: WindowEffect.mica,
dark: isDark,
);
await Window.setEffect(effect: WindowEffect.mica, dark: isDark);
Log.i('WindowsAcrylicService 应用 Mica 特效 (isDark=$isDark)');
} else {
// Win10: Acrylic 半透明
@@ -90,23 +87,44 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
effect: WindowEffect.acrylic,
dark: isDark,
color: isDark
? const Color(0xCC1F1F1F) // 深色半透明
? const Color(0xCC1F1F1F) // 深色半透明
: const Color(0xCCF3F3F3), // 浅色半透明
);
Log.i('WindowsAcrylicService 应用 Acrylic 特效 (isDark=$isDark)');
}
// 修复setEffect 调用 DwmExtendFrameIntoClientArea(margins={-1}) 后,
// Flutter 视图子窗口尺寸与实际客户区不匹配,导致上方黑色+下方低分辨率。
// 通过强制重设窗口大小触发 WM_SIZE → MoveWindow 重新布局 Flutter 视图。
await _refreshWindowLayout();
} catch (e) {
Log.e('WindowsAcrylicService.applyEffect 失败: $e');
// 降级:禁用特效
try {
await Window.setEffect(
effect: WindowEffect.disabled,
dark: isDark,
);
await Window.setEffect(effect: WindowEffect.disabled, dark: isDark);
} catch (_) {}
}
}
/// 刷新窗口布局
///
/// DwmExtendFrameIntoClientArea 会改变客户区边界计算方式,
/// 导致 Flutter 视图child_content_ HWND无法正确填充窗口。
/// 通过重设窗口大小触发 WM_SIZE → Win32Window::MessageHandler → MoveWindow
/// 让 Flutter 视图重新适配客户区尺寸。
Future<void> _refreshWindowLayout() async {
try {
final size = await windowManager.getSize();
// 先设为稍大尺寸再恢复,确保 WM_SIZE 一定会被触发
await windowManager.setSize(const Size(1281, 721));
await Future<void>.delayed(const Duration(milliseconds: 50));
await windowManager.setSize(size);
Log.i('WindowsAcrylicService 窗口布局刷新完成');
} catch (e) {
Log.w('WindowsAcrylicService._refreshWindowLayout 失败: $e');
}
}
/// 检测当前系统是否支持 Mica AltWin11 build >= 22621
///
/// 使用 device_info_plus 读取精确的 Windows build 号。

View File

@@ -1,14 +1,14 @@
/// ============================================================
/// 闲言APP — 剪贴板桥接工具(含隐私协议守卫)
/// 创建时间: 2026-05-17
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-21
/// 作用: 统一剪贴板读取入口鸿蒙平台通过原生MethodChannel读取
/// 其他平台使用Flutter Clipboard未同意隐私协议时禁止读取
/// 鸿蒙端拦截Flutter标准Clipboard通道修复系统粘贴不工作
/// 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理(需 READ_PASTEBOARD 权限)
/// 桌面端macOS/Windows支持富文本HTML读写
/// 上次更新: 新增富文本HTML支持 — 新增 setRichText/getHtml/hasHtml
/// 方法,桌面端通过 apps.xy.xianyan/clipboard 通道写入HTML
/// 移动端/Web 降级为纯文本去除HTML标签
/// 上次更新: 移除原生端 flutter/platform 通道拦截器(此前拦截器覆盖引擎
/// 原生 Clipboard/SystemChrome.setPreferredOrientations 实现
/// 导致横屏翻转失效),剪贴板功能改由引擎原生处理
/// ============================================================
import 'package:flutter/services.dart';
@@ -45,16 +45,15 @@ class ClipboardBridge {
/// 安装鸿蒙端标准剪贴板拦截器
/// Flutter标准TextInputPlugin长按粘贴使用SystemChannels.platform的
/// Clipboard.getData方法,鸿蒙端Flutter引擎未实现此方法,
/// 导致所有输入框粘贴无反应。此拦截器将标准Clipboard调用
/// 路由到鸿蒙原生pasteboard API。
/// Clipboard.getData方法Flutter鸿蒙引擎已在 PlatformChannel.ets 中
/// 实现了 Clipboard 方法,无需 Dart 端额外拦截。
///
/// 实现原理在原生端EntryAbility.ets中注册flutter/platform通道的
/// 剪贴板方法处理器。Dart端此方法仅作为标记实际拦截在原生端完成
/// 此方法保留为空操作,仅为向后兼容。剪贴板读写由引擎原生处理,
/// 粘贴所需 READ_PASTEBOARD 权限已在 module.json5 中声明
static void installOhosClipboardInterceptor() {
if (!_isOhos || _ohosInterceptorInstalled) return;
_ohosInterceptorInstalled = true;
Log.i('ClipboardBridge: 鸿蒙端剪贴板拦截器标记已设置(原生端负责实际拦截)');
Log.i('ClipboardBridge: 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理');
}
// ============================================================
@@ -145,10 +144,9 @@ class ClipboardBridge {
if (_isOhos) {
try {
if (plainText != null && plainText.isNotEmpty) {
await _channel.invokeMethod<void>(
'Clipboard.setData',
{'text': plainText},
);
await _channel.invokeMethod<void>('Clipboard.setData', {
'text': plainText,
});
}
return;
} on MissingPluginException {

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 编辑器主页面 (重写)
// 创建时间: 2026-04-20
// 更新时间: 2026-05-02
// 更新时间: 2026-06-19
// 作用: 卡片/壁纸编辑器入口,直接打开 ProEditorPage
// 上次更新: 移除旧底部状态栏功能已整合至EditorBottomToolbar
// 上次更新: 修复 Windows 端 toImage 失败导致白屏,增加超时和多重回退
// ============================================================
import 'dart:ui' as ui;
@@ -32,13 +32,36 @@ class _EditorPageState extends State<EditorPage> {
@override
void initState() {
super.initState();
_createDefaultBackground()
.then((bytes) {
if (mounted) setState(() => _backgroundBytes = bytes);
})
.catchError((Object e) {
Log.w('背景创建失败,使用默认背景', e);
});
_initBackground();
}
/// 初始化背景图片,带超时和多重回退
Future<void> _initBackground() async {
try {
// 设置 3 秒超时,避免 toImage 在 Windows 上卡死
final bytes = await _createDefaultBackground().timeout(
const Duration(seconds: 3),
);
Log.i('编辑器背景创建成功,大小: ${bytes.length} bytes');
if (mounted) setState(() => _backgroundBytes = bytes);
} catch (e) {
Log.e('背景创建失败,尝试回退', e);
try {
final fallback = await _createFallbackBackground().timeout(
const Duration(seconds: 2),
);
Log.i('编辑器回退背景创建成功,大小: ${fallback.length} bytes');
if (mounted) setState(() => _backgroundBytes = fallback);
} catch (e2) {
Log.e('回退背景也失败,使用内存编码', e2);
// 最终回退:直接使用 1x1 像素 PNG 编码
if (mounted) {
final minimal = _minimalPng();
Log.i('编辑器使用最小 PNG大小: ${minimal.length} bytes');
setState(() => _backgroundBytes = minimal);
}
}
}
}
@override
@@ -82,7 +105,8 @@ class _EditorPageState extends State<EditorPage> {
Future<Uint8List> _createDefaultBackground() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
const size = Size(1080, 1920);
// 使用较小尺寸避免 Windows 端内存不足导致 toImage 失败
const size = Size(540, 960);
const gradient = LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF4ECDC4)],
@@ -103,6 +127,45 @@ class _EditorPageState extends State<EditorPage> {
size.height.toInt(),
);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData!.buffer.asUint8List();
}
/// 创建回退背景(小尺寸纯色),防止主背景创建失败导致白屏
Future<Uint8List> _createFallbackBackground() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
const size = Size(100, 100);
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = const Color(0xFF1A1A2E),
);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.toInt(),
size.height.toInt(),
);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData!.buffer.asUint8List();
}
/// 最小 1x1 像素 PNG硬编码确保编辑器一定能渲染
///
/// 这是一个有效的 1x1 深蓝色 PNG 文件的字节数据,
// 作为最终回退,避免任何 toImage 调用失败时白屏
Uint8List _minimalPng() {
// 1x1 像素 #1A1A2E (深蓝色) 的 PNG 编码
return Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8bit RGB
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x08, 0xD7, 0x63, 0xA0, 0xA0, 0x20, 0x20, // compressed data
0x00, 0x00, 0x00, 0x44, 0x00, 0x01, 0xA6, 0x21, // for #1A1A2E
0x80, 0x9E, 0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, // IEND chunk
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]);
}
}

View File

@@ -613,16 +613,21 @@ class ProEditorPageState extends State<ProEditorPage>
final borderRadius = _canvasStyle.useIndependentRadius
? BorderRadius.only(
topLeft: Radius.circular(
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0),
),
topRight: Radius.circular(
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0),
),
bottomLeft: Radius.circular(
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0),
),
bottomRight: Radius.circular(
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0),
),
)
: BorderRadius.circular(
_canvasStyle.borderRadius.clamp(0.0, 80.0));
_canvasStyle.borderRadius.clamp(0.0, 80.0),
);
if (borderRadius == BorderRadius.zero) return child;
return Container(
color: p.bgCanvas,
@@ -726,7 +731,11 @@ class ProEditorPageState extends State<ProEditorPage>
}
},
onEditorZoomMatrix4Change: (matrix) {
_zoomScaleNotifier.value = matrix.getMaxScaleOnAxis();
final scale = matrix.getMaxScaleOnAxis();
// 防止 NaN/Infinite 值导致 Matrix4 entries must be finite 崩溃
if (scale.isFinite) {
_zoomScaleNotifier.value = scale;
}
},
),
),

View File

@@ -136,7 +136,7 @@ class ProEditorBridge {
mainEditor: pro.MainEditorConfigs(
enableZoom: true,
editorMinScale: 0.5,
boundaryMargin: const EdgeInsets.all(double.infinity),
boundaryMargin: const EdgeInsets.all(10000),
safeArea: const pro.EditorSafeArea.none(),
widgets: pro.MainEditorWidgets(
appBar: (editor, rebuildStream) => pro.ReactiveAppbar(

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 桌面端托盘控制器
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-19
/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider管理托盘生命周期
/// 上次更新: 初始创建,实现 DesktopTrayController
/// 上次更新: 添加语言变化监听,托盘菜单支持动态多语言
/// ============================================================
import 'dart:async';
@@ -48,8 +48,10 @@ class DesktopTrayController {
final WidgetRef _ref;
StreamSubscription<TrayEvent>? _eventSub;
ProviderSubscription<int>? _unreadSub;
ProviderSubscription<GeneralSettingsState>? _generalSettingsSub;
bool _initialized = false;
bool _isWindowVisible = true;
/// 防止 popUpContextMenu 触发的 performClick 导致重入
bool _isPopUpMenuInProgress = false;
@@ -89,16 +91,27 @@ class DesktopTrayController {
await trayService.setUnreadBadge(unreadCount);
// 6. 监听未读数变化
_unreadSub = _ref.listenManual(
trayUnreadCountProvider,
(previous, next) {
_onUnreadCountChanged(next);
},
);
_unreadSub = _ref.listenManual(trayUnreadCountProvider, (previous, next) {
_onUnreadCountChanged(next);
});
// 7. 监听托盘事件
_eventSub = trayService.events.listen(_onTrayEvent);
// 8. 监听语言设置变化,动态更新托盘菜单
_generalSettingsSub = _ref.listenManual(generalSettingsProvider, (
previous,
next,
) {
if (previous?.languageId != next.languageId) {
_updateMenu();
// 更新 Tooltip
final unreadCount = _ref.read(trayUnreadCountProvider);
final labels = TrayMenuLabels.forLanguage(next.languageId);
trayService.setToolTip(labels.formatTooltip(unreadCount));
}
});
Log.i('DesktopTrayController 初始化完成');
} catch (e, st) {
Log.e('DesktopTrayController 初始化失败', e, st);
@@ -111,6 +124,8 @@ class DesktopTrayController {
_eventSub = null;
_unreadSub?.close();
_unreadSub = null;
_generalSettingsSub?.close();
_generalSettingsSub = null;
if (_initialized) {
try {
@@ -183,9 +198,7 @@ class DesktopTrayController {
final languageId = _ref.read(generalSettingsProvider).languageId;
final labels = TrayMenuLabels.forLanguage(languageId);
await DesktopTrayService.instance.setToolTip(
labels.formatTooltip(count),
);
await DesktopTrayService.instance.setToolTip(labels.formatTooltip(count));
// 未读数变化时也更新菜单(最近阅读列表可能变化)
await _updateMenu();
@@ -304,9 +317,7 @@ class DesktopTrayController {
void _onToggleDarkMode() {
final settings = _ref.read(themeSettingsProvider);
final newMode = settings.isDark
? AppThemeMode.light
: AppThemeMode.dark;
final newMode = settings.isDark ? AppThemeMode.light : AppThemeMode.dark;
_ref.read(themeSettingsProvider.notifier).setThemeMode(newMode);
// 主题变化会触发 onThemeChanged 回调,无需在此更新菜单
}

View File

@@ -1,13 +1,11 @@
/// ============================================================
/// 闲言APP — 桌面端自定义窗口标题栏
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-22
/// 作用: 软件样式标题栏,替代系统默认标题栏,支持动态主题+动态样式
/// 上次更新: 初始创建,实现 macOS 风格(红黄绿) + Windows 风格(─ ▢ ✕)
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单
/// ============================================================
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -17,6 +15,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/platform/platform_utils.dart' as pu;
import '../../features/settings/providers/theme_settings_provider.dart';
import '../../shared/widgets/window_size_popup.dart';
/// 标题栏样式配置(动态样式)
class DesktopTitleBarStyle {
@@ -167,7 +166,8 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
final isAmoled = theme.isAmoled;
// 根据平台选择默认样式
final style = widget.style ??
final style =
widget.style ??
(pu.isMacOS
? const DesktopTitleBarStyle.macOS()
: const DesktopTitleBarStyle.windows());
@@ -180,24 +180,29 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
? const Color(0xFFE5E5E7)
: const Color(0xFF1D1D1F);
// Windows: C++ HTTRANSPARENT 方案处理拖拽
// 子类化 Flutter 子窗口WM_NCHITTEST 标题栏区域返回 HTTRANSPARENT
// Windows 转发到父窗口window_manager_plugin.cpp 返回 HTCAPTION → 原生拖拽。
// 无需 Dart 侧 startDraggingMethodChannel 延迟导致卡顿)。
if (pu.isWindows) {
return Container(
height: style.height,
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
child: _buildWindowsLayout(style, foregroundColor),
);
}
// macOS: 使用 GestureDetector 处理拖拽和双击
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (_) => windowManager.startDragging(),
onPanDown: (_) {
windowManager.startDragging();
},
onDoubleTap: _toggleMaximize,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: style.blurRadius,
sigmaY: style.blurRadius,
),
child: Container(
height: style.height,
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
child: pu.isMacOS
? _buildMacOSLayout(style, foregroundColor)
: _buildWindowsLayout(style, foregroundColor),
),
),
child: Container(
height: style.height,
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
child: _buildMacOSLayout(style, foregroundColor),
),
);
}
@@ -250,9 +255,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
SizedBox(width: style.buttonSpacing),
_MacOSTrafficButton(
color: const Color(0xFF28C840),
icon: _isMaximized
? Icons.fullscreen_exit
: Icons.fullscreen,
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
isHovering: _isHoveringMaximize,
onHover: (v) => setState(() => _isHoveringMaximize = v),
onTap: _toggleMaximize,
@@ -333,19 +336,17 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
iconSize: 16,
isHovering: _isHoveringMinimize,
onHover: (v) => setState(() => _isHoveringMinimize = v),
onTap: () => windowManager.minimize(),
onTap: (_) => windowManager.minimize(),
foregroundColor: foregroundColor,
width: 46,
height: style.height,
),
_WindowsControlButton(
icon: _isMaximized
? Icons.fullscreen_exit
: Icons.fullscreen,
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
iconSize: 14,
isHovering: _isHoveringMaximize,
onHover: (v) => setState(() => _isHoveringMaximize = v),
onTap: _toggleMaximize,
onTap: (btnCtx) => WindowSizePopup.show(btnCtx),
foregroundColor: foregroundColor,
width: 46,
height: style.height,
@@ -355,7 +356,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
iconSize: 16,
isHovering: _isHoveringClose,
onHover: (v) => setState(() => _isHoveringClose = v),
onTap: () => windowManager.close(),
onTap: (_) => windowManager.close(),
foregroundColor: foregroundColor,
width: 46,
height: style.height,
@@ -460,7 +461,9 @@ class _WindowsControlButton extends StatefulWidget {
final double iconSize;
final bool isHovering;
final ValueChanged<bool> onHover;
final VoidCallback onTap;
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
final void Function(BuildContext buttonContext) onTap;
final Color foregroundColor;
final double width;
final double height;
@@ -485,7 +488,7 @@ class _WindowsControlButtonState extends State<_WindowsControlButton> {
onEnter: (_) => widget.onHover(true),
onExit: (_) => widget.onHover(false),
child: GestureDetector(
onTap: widget.onTap,
onTap: () => widget.onTap(context),
child: Container(
width: widget.width,
height: widget.height,

View File

@@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../../core/theme/app_spacing.dart';
import '../../../../../core/theme/app_theme.dart';
@@ -597,7 +598,7 @@ mixin ReadLaterPageUiMixin on ReadLaterPageStateAccessor {
),
),
onPressed: () {
Navigator.of(context).pushNamed('/discover');
context.go('/discover');
},
),
],

View File

@@ -7,7 +7,8 @@
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Material, MaterialType, PageView, Colors;
import 'package:flutter/material.dart'
show Material, MaterialType, PageView, Colors;
import '../../../../core/constants/app_constants.dart';
import '../../../../core/storage/kv_storage.dart';
@@ -70,8 +71,9 @@ class NewFeaturesDialog {
// 1. 精确匹配(去掉 v 前缀比较)
final cleanVer = version.startsWith('v') ? version.substring(1) : version;
for (final e in AppUpdateLog.entries) {
final entryVer =
e.version.startsWith('v') ? e.version.substring(1) : e.version;
final entryVer = e.version.startsWith('v')
? e.version.substring(1)
: e.version;
if (entryVer == cleanVer) return e;
}
@@ -80,8 +82,9 @@ class NewFeaturesDialog {
if (parts.length >= 2) {
final majorMinor = '${parts[0]}.${parts[1]}';
for (final e in AppUpdateLog.entries) {
final entryVer =
e.version.startsWith('v') ? e.version.substring(1) : e.version;
final entryVer = e.version.startsWith('v')
? e.version.substring(1)
: e.version;
if (entryVer.startsWith('$majorMinor.')) return e;
}
}
@@ -100,11 +103,7 @@ class NewFeaturesDialog {
await showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
return _NewFeaturesCarousel(
entry: entry,
t: t,
ext: ext,
);
return _NewFeaturesCarousel(entry: entry, t: t, ext: ext);
},
);
}
@@ -150,25 +149,31 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
final cards = <_FeatureCardData>[];
// 第一张卡片:版本概览
cards.add(_FeatureCardData(
icon: CupertinoIcons.sparkles,
iconColor: widget.ext.accent,
title: '${widget.entry.version} 更新',
subtitle: widget.entry.date,
description: widget.t.onboarding.knowNewFeatures
.replaceAll('{0}', AppVersion.version),
isOverview: true,
));
cards.add(
_FeatureCardData(
icon: CupertinoIcons.sparkles,
iconColor: widget.ext.accent,
title: '${widget.entry.version} 更新',
subtitle: widget.entry.date,
description: widget.t.onboarding.knowNewFeatures.replaceAll(
'{0}',
AppVersion.version,
),
isOverview: true,
),
);
// 后续卡片:每个变更点
for (final change in widget.entry.changes) {
final parsed = _parseChange(change);
cards.add(_FeatureCardData(
icon: parsed.icon,
iconColor: parsed.color,
title: parsed.title,
description: parsed.description,
));
cards.add(
_FeatureCardData(
icon: parsed.icon,
iconColor: parsed.color,
title: parsed.title,
description: parsed.description,
),
);
}
return cards;
@@ -182,11 +187,26 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
String? emoji;
String rest = trimmed;
if (trimmed.isNotEmpty) {
// 匹配常见 emoji 前缀
final emojiMatch = RegExp(r'^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉]+)\s*').firstMatch(trimmed);
if (emojiMatch != null) {
emoji = emojiMatch.group(1);
rest = trimmed.substring(emojiMatch.end).trim();
// 使用 code point 检测 emoji 前缀(避免正则 Unicode 转义兼容性问题)
final runes = trimmed.runes.toList();
int emojiEnd = 0;
while (emojiEnd < runes.length) {
final cp = runes[emojiEnd];
final isEmoji =
(cp >= 0x1F300 && cp <= 0x1FAFF) || // Emoji 主范围
(cp >= 0x2600 && cp <= 0x27BF) || // 杂项符号(☀➿等)
(cp >= 0x2B00 && cp <= 0x2BFF) || // 其他符号
'✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉'.runes.contains(cp) ||
(cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selector
(cp >= 0x1F900 && cp <= 0x1F9FF); // 补充符号
if (!isEmoji) break;
emojiEnd++;
}
if (emojiEnd > 0) {
emoji = String.fromCharCodes(runes.sublist(0, emojiEnd));
rest = trimmed
.substring(String.fromCharCodes(runes.sublist(0, emojiEnd)).length)
.trim();
}
}
@@ -225,7 +245,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
}
if (emoji.contains('📱')) {
return _IconMatch(CupertinoIcons.device_phone_portrait, ext.iconTintCyan);
return _IconMatch(
CupertinoIcons.device_phone_portrait,
ext.iconTintCyan,
);
}
if (emoji.contains('🚀') || emoji.contains('')) {
return _IconMatch(CupertinoIcons.sparkles, ext.accent);
@@ -255,19 +278,28 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
if (text.contains('修复') || lower.contains('fix') || lower.contains('bug')) {
return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor);
}
if (text.contains('优化') || text.contains('改进') || text.contains('提升') || lower.contains('optim')) {
if (text.contains('优化') ||
text.contains('改进') ||
text.contains('提升') ||
lower.contains('optim')) {
return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple);
}
if (text.contains('性能') || lower.contains('performance')) {
return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow);
}
if (text.contains('多语言') || text.contains('翻译') || lower.contains('language')) {
if (text.contains('多语言') ||
text.contains('翻译') ||
lower.contains('language')) {
return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue);
}
if (text.contains('安全') || text.contains('密保') || lower.contains('security')) {
if (text.contains('安全') ||
text.contains('密保') ||
lower.contains('security')) {
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
}
if (text.contains('框架') || text.contains('架构') || lower.contains('framework')) {
if (text.contains('框架') ||
text.contains('架构') ||
lower.contains('framework')) {
return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint);
}
@@ -375,7 +407,11 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
}
/// 构建单张卡片
Widget _buildCard(_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
Widget _buildCard(
_FeatureCardData card,
AppThemeExtension ext,
bool isOverview,
) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(
@@ -429,7 +465,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
/// 构建带光晕效果的图标
Widget _buildIconWithGlow(
_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
_FeatureCardData card,
AppThemeExtension ext,
bool isOverview,
) {
final iconSize = isOverview ? 64.0 : 56.0;
final containerSize = isOverview ? 120.0 : 100.0;
@@ -459,11 +498,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
width: 1.5,
),
),
child: Icon(
card.icon,
size: iconSize,
color: card.iconColor,
),
child: Icon(card.icon, size: iconSize, color: card.iconColor),
),
),
);
@@ -480,9 +515,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
),
decoration: BoxDecoration(
color: ext.bgCard,
border: Border(
top: BorderSide(color: ext.overlaySubtle, width: 0.5),
),
border: Border(top: BorderSide(color: ext.overlaySubtle, width: 0.5)),
),
child: SafeArea(
top: false,
@@ -544,9 +577,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(
isLastPage
? widget.t.common.gotIt
: widget.t.common.confirm,
isLastPage ? widget.t.common.gotIt : widget.t.common.confirm,
style: AppTypography.body.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,

View File

@@ -1,9 +1,9 @@
// ============================================================
/// ============================================================
// 闲言APP — 应用入口
// 创建时间: 2026-04-20
/// 更新时间: 2026-06-05
/// 更新时间: 2026-06-19
/// 作用: main 函数,初始化存储 + 液态玻璃 + 异常捕获 + 启动 App
/// 上次更新: 新增PlatformCapabilities.init()平台能力注册表初始化
/// 上次更新: 移除 main.dart 中的 flutter_acrylic 初始化,统一由 WindowsAcrylicService 管理
// ============================================================
import 'dart:async';
@@ -65,6 +65,7 @@ void main() async {
Future<void> _appMain() async {
if (pu.isDesktop) {
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
minimumSize: Size(400, 600),
title: '闲言',
@@ -74,6 +75,10 @@ Future<void> _appMain() async {
// macOS: 隐藏原生红黄绿按钮,由 Flutter 侧自绘
skipTaskbar: false,
);
// Windows: flutter_acrylic 初始化统一由 WindowsAcrylicService 管理
// Window.initialize() 和 Window.setEffect() 均在 app.dart 的 _initWindowEffect 中调用
// 不在此处调用 Window.initialize(),避免重复初始化和布局异常
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
});

View File

@@ -1,9 +1,10 @@
// ============================================================
// 闲言APP — 键盘适配工具
// 创建时间: 2026-05-02
// 更新时间: 2026-05-30
// 更新时间: 2026-06-21
// 作用: 解决输入法面板遮挡底部Sheet/弹窗的通用方案 + 全局键盘管理
// 上次更新: 集成flutter_keyboard_visibility替换MediaQuery轮询检测键盘可见性
// 上次更新: 鸿蒙端 flutter_keyboard_visibility 插件无原生实现,
// 优雅降级为 MediaQuery 检测,避免 MissingPluginException
// ============================================================
import 'dart:async';
@@ -11,6 +12,7 @@ import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
/// 键盘安全底部弹窗包装器
///
@@ -118,15 +120,36 @@ class KeyboardManager {
static final KeyboardManager instance = KeyboardManager._();
/// 键盘可见性控制器flutter_keyboard_visibility
static final KeyboardVisibilityController _keyboardVisibility =
KeyboardVisibilityController();
/// 鸿蒙端无原生实现,延迟初始化并捕获异常,降级为不可用
static KeyboardVisibilityController? _keyboardVisibility;
static bool _keyboardVisibilityAvailable = true;
static KeyboardVisibilityController? get _keyboardVisibilityController {
if (!_keyboardVisibilityAvailable) return null;
if (_keyboardVisibility != null) return _keyboardVisibility;
try {
_keyboardVisibility = KeyboardVisibilityController();
return _keyboardVisibility;
} catch (e) {
_keyboardVisibilityAvailable = false;
return null;
}
}
/// 流式键盘可见性变更
static Stream<bool> get keyboardVisibilityStream =>
_keyboardVisibility.onChange;
/// 鸿蒙端插件不可用时返回空流(永不触发),调用方需用 MediaQuery 兜底
static Stream<bool> get keyboardVisibilityStream {
if (pu.isOhos) return const Stream<bool>.empty();
return _keyboardVisibilityController?.onChange ??
const Stream<bool>.empty();
}
/// 同步获取键盘是否可见无需BuildContext
static bool get isKeyboardVisibleSync => _keyboardVisibility.isVisible;
/// 鸿蒙端插件不可用时返回 false调用方需用 MediaQuery 兜底
static bool get isKeyboardVisibleSync {
if (pu.isOhos) return false;
return _keyboardVisibilityController?.isVisible ?? false;
}
/// 页面生命周期管理
final Set<String> _activePages = {};
@@ -170,8 +193,10 @@ class KeyboardManager {
}
/// 键盘是否可见使用KeyboardVisibilityController无需BuildContext轮询
/// 鸿蒙端降级为 MediaQuery 检测
static bool isKeyboardVisible(BuildContext context) {
return _keyboardVisibility.isVisible;
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
return _keyboardVisibilityController?.isVisible ?? false;
}
/// 键盘高度仍需MediaQueryflutter_keyboard_visibility不提供高度
@@ -191,8 +216,10 @@ class KeyboardSafe {
}
/// 键盘是否可见使用KeyboardVisibilityController无需BuildContext轮询
/// 鸿蒙端降级为 MediaQuery 检测
static bool isKeyboardVisible(BuildContext context) {
return KeyboardManager._keyboardVisibility.isVisible;
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
return KeyboardManager._keyboardVisibilityController?.isVisible ?? false;
}
static Future<T?> showSheet<T>({
@@ -261,7 +288,9 @@ class _ManagedCupertinoTextFieldState extends State<ManagedCupertinoTextField> {
@override
void initState() {
super.initState();
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(_onKeyboardVisibility);
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(
_onKeyboardVisibility,
);
}
@override

View File

@@ -0,0 +1,413 @@
/// ============================================================
/// 闲言APP — 窗口大小预设弹出菜单
/// 创建时间: 2026-06-22
/// 更新时间: 2026-06-22
/// 作用: 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
/// 上次更新: 从 adaptive_nav_bar.dart 提取为共享组件,供 DesktopWindowTitleBar 和 AdaptiveNavBar 复用
/// ============================================================
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:window_manager/window_manager.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_theme.dart';
import '../../core/theme/glass_tokens.dart';
/// 窗口大小预设弹出菜单
///
/// 使用 [WindowSizePopup.show] 在指定按钮位置弹出 3×2 网格菜单。
/// iOS 26 Liquid Glass 风格Alert 层级毛玻璃。
class WindowSizePopup {
WindowSizePopup._();
/// 在 [buttonContext] 对应的按钮下方弹出窗口大小预设菜单
///
/// [buttonContext] 应为按钮自身的 BuildContext用于计算弹出位置。
static Future<void> show(BuildContext buttonContext) async {
final isMaximized = await windowManager.isMaximized();
if (!buttonContext.mounted) return;
// 获取按钮在屏幕中的位置,用于弹出菜单定位
final renderBox = buttonContext.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final buttonPos = renderBox.localToGlobal(Offset.zero);
final buttonSize = renderBox.size;
// 通过 OverlayEntry 弹出自定义菜单,避免阻塞 UI 线程
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _WindowSizePopupContent(
buttonPos: buttonPos,
buttonSize: buttonSize,
isMaximized: isMaximized,
onDismiss: () => entry.remove(),
),
);
Overlay.of(buttonContext, rootOverlay: true).insert(entry);
}
}
/// 窗口大小弹出菜单内容iOS 26 Liquid Glass 风格)
/// 3 列 × 2 行网格布局,在按钮下方定位弹出
class _WindowSizePopupContent extends StatefulWidget {
const _WindowSizePopupContent({
required this.buttonPos,
required this.buttonSize,
required this.isMaximized,
required this.onDismiss,
});
/// 按钮左上角的全局坐标
final Offset buttonPos;
/// 按钮尺寸
final Size buttonSize;
/// 当前窗口是否已最大化
final bool isMaximized;
/// 关闭菜单回调
final VoidCallback onDismiss;
@override
State<_WindowSizePopupContent> createState() =>
_WindowSizePopupContentState();
}
class _WindowSizePopupContentState extends State<_WindowSizePopupContent>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
late final Animation<double> _opacity;
/// 弹出菜单宽度
static const double _popupWidth = 348.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 180),
reverseDuration: const Duration(milliseconds: 120),
vsync: this,
);
_scale = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 关闭菜单(先播放退出动画再移除 Overlay
void _close() {
_controller.reverse().then((_) {
if (mounted) widget.onDismiss();
});
}
/// 应用窗口尺寸
Future<void> _applySize(int? w, int? h) async {
_close();
if (w == null || h == null) {
// 最大化 / 还原
if (widget.isMaximized) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
} else {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
}
await windowManager.setSize(Size(w.toDouble(), h.toDouble()));
}
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final mediaQuery = MediaQuery.of(context);
// 计算弹出位置:右对齐按钮,出现在按钮下方
double left = widget.buttonPos.dx + widget.buttonSize.width - _popupWidth;
const margin = 8.0;
if (left < margin) left = margin;
if (left + _popupWidth > mediaQuery.size.width - margin) {
left = mediaQuery.size.width - _popupWidth - margin;
}
final top = widget.buttonPos.dy + widget.buttonSize.height + 4;
return Stack(
children: [
// 全屏透明遮罩,点击外部关闭
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _close,
child: const SizedBox.expand(),
),
),
// 弹出菜单本体
Positioned(
left: left,
top: top,
child: AnimatedBuilder(
animation: _controller,
builder: (_, child) => Opacity(
opacity: _opacity.value,
child: Transform.scale(
scale: 0.92 + 0.08 * _scale.value,
alignment: Alignment.topRight,
child: child,
),
),
child: _buildPopup(ext),
),
),
],
);
}
/// 构建弹出菜单容器
Widget _buildPopup(AppThemeExtension ext) {
return Container(
width: _popupWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: GlassTokens.borderColor(ext.isDark),
width: GlassTokens.borderWidth,
),
boxShadow: [
BoxShadow(
color: GlassTokens.shadowColor(ext.isDark),
blurRadius: GlassTokens.shadowBlur,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: GlassTokens.alertBlur,
sigmaY: GlassTokens.alertBlur,
),
child: Container(
color: ext.glassColor.withValues(
alpha: ext.isDark
? GlassTokens.alertOpacityDark
: GlassTokens.alertOpacityLight,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(ext),
Container(
height: 0.5,
color: ext.dividerOnCard,
),
_buildGrid(ext),
],
),
),
),
),
);
}
/// 标题栏
Widget _buildHeader(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Row(
children: [
Icon(
CupertinoIcons.rectangle_split_3x1,
size: 14,
color: ext.textSecondary,
),
const SizedBox(width: 6),
Text(
'窗口大小',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ext.textSecondary,
),
),
const Spacer(),
Text(
widget.isMaximized ? '当前:最大化' : '当前:自定义',
style: TextStyle(fontSize: 11, color: ext.textHint),
),
],
),
);
}
/// 3×2 网格
Widget _buildGrid(AppThemeExtension ext) {
// 预设尺寸列表:(图标, 标签, 宽, 高)
final presets = <_SizePreset>[
const _SizePreset(
icon: CupertinoIcons.device_phone_portrait,
label: '小窗',
width: 800,
height: 600,
),
const _SizePreset(
icon: CupertinoIcons.desktopcomputer,
label: '标准',
width: 1024,
height: 768,
),
const _SizePreset(
icon: CupertinoIcons.device_laptop,
label: '宽屏',
width: 1280,
height: 720,
),
const _SizePreset(
icon: CupertinoIcons.tv,
label: '大屏',
width: 1440,
height: 900,
),
const _SizePreset(
icon: CupertinoIcons.rectangle_on_rectangle,
label: '全高清',
width: 1920,
height: 1080,
),
_SizePreset(
icon: widget.isMaximized
? CupertinoIcons.arrow_down_right_square
: CupertinoIcons.rectangle_fill,
label: widget.isMaximized ? '还原' : '最大化',
width: null,
height: null,
),
];
return Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: AppSpacing.sm,
crossAxisSpacing: AppSpacing.sm,
childAspectRatio: 1.05,
children: presets
.map((p) => _HoverCell(
preset: p,
ext: ext,
onTap: () => _applySize(p.width, p.height),
))
.toList(),
),
);
}
}
/// 窗口尺寸预设数据模型
class _SizePreset {
const _SizePreset({
required this.icon,
required this.label,
required this.width,
required this.height,
});
final IconData icon;
final String label;
final int? width;
final int? height;
/// 尺寸显示文本
String get dimensionText =>
width != null && height != null ? '$width × $height' : '';
}
/// 可悬停的尺寸选择单元格
class _HoverCell extends StatefulWidget {
const _HoverCell({
required this.preset,
required this.ext,
required this.onTap,
});
final _SizePreset preset;
final AppThemeExtension ext;
final VoidCallback onTap;
@override
State<_HoverCell> createState() => _HoverCellState();
}
class _HoverCellState extends State<_HoverCell> {
bool _isHovering = false;
@override
Widget build(BuildContext context) {
final ext = widget.ext;
final preset = widget.preset;
final accent = ext.accent;
return MouseRegion(
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: _isHovering
? accent.withValues(alpha: 0.14)
: ext.overlaySubtle.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: _isHovering
? accent.withValues(alpha: 0.45)
: const Color(0x00000000),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
preset.icon,
size: 22,
color: _isHovering ? accent : ext.iconSecondary,
),
const SizedBox(height: 4),
Text(
preset.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _isHovering ? accent : ext.textPrimary,
),
),
if (preset.dimensionText.isNotEmpty) ...[
const SizedBox(height: 1),
Text(
preset.dimensionText,
style: TextStyle(fontSize: 10, color: ext.textHint),
),
],
],
),
),
),
);
}
}

View File

@@ -17,7 +17,7 @@ import flutter_app_group_directory
import flutter_image_compress_macos
import flutter_inappwebview_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import flutter_secure_storage_macos
import flutter_tts
import flutter_webrtc
import gal
@@ -56,7 +56,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))

View File

@@ -0,0 +1,222 @@
// ============================================================
// 闲言APP — 鸿蒙端自定义 PlatformPlugin 中间件
// 创建时间: 2026-06-21
// 更新时间: 2026-06-21
// 作用: 拦截 Flutter 引擎的剪贴板读取请求,使用系统安全控件
// PasteButton 替代 READ_PASTEBOARD 系统级权限申请。
// READ_PASTEBOARD 为 system_basic 级权限,普通应用无法通过
// requestPermissionsFromUser 申请PasteButton 点击后授予
// 临时授权(持续到灭屏/切后台/退出),无需声明权限。
// 架构说明: 此中间件不修改 SDK 源码,通过继承 PlatformPlugin 和
// PlatformPluginCallback 实现剪贴板方法覆盖SDK 升级不受影响。
// ============================================================
import PlatformPlugin, {
PlatformPluginCallback,
PlatformPluginDelegate,
} from '@ohos/flutter_ohos/src/main/ets/plugin/PlatformPlugin';
import PlatformChannel from '@ohos/flutter_ohos/src/main/ets/embedding/engine/systemchannels/PlatformChannel';
import common from '@ohos.app.ability.common';
import { MethodResult } from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodChannel';
import Any from '@ohos/flutter_ohos/src/main/ets/plugin/common/Any';
import Log from '@ohos/flutter_ohos/src/main/ets/util/Log';
import { pasteboard, BusinessError } from '@kit.BasicServicesKit';
import { ComponentContent } from '@kit.ArkUI';
const TAG = 'CustomPlatformPlugin';
// ============================================================
// 粘贴安全控件对话框PasteButton
// ============================================================
/// 粘贴对话框控制器(用于 PasteButton 回调传递)
class PasteDialogController {
onPaste: ((text: string) => void) | null = null;
onClose: (() => void) | null = null;
}
/// 粘贴对话框内容构建器(使用系统安全控件 PasteButton
/// PasteButton 是鸿蒙系统安全控件,点击后授予临时剪贴板读取授权
@Builder
function PasteDialogContent(controller: PasteDialogController) {
Column({ space: 16 }) {
Text('粘贴')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text('点击下方按钮粘贴剪贴板内容')
.fontSize(14)
.fontColor('#666666')
PasteButton()
.padding({ top: 12, bottom: 12, left: 24, right: 24 })
.onClick((event: ClickEvent, result: PasteButtonOnClickResult) => {
if (PasteButtonOnClickResult.SUCCESS === result) {
try {
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
let pasteText: string = '';
if (pasteData && pasteData.getRecordCount() > 0) {
const record = pasteData.getRecordAt(0);
if (record.mimeType === pasteboard.MIMETYPE_TEXT_PLAIN) {
pasteText = record.plainText ?? '';
} else if (record.mimeType === pasteboard.MIMETYPE_TEXT_HTML) {
pasteText = record.htmlText ?? '';
}
}
controller.onPaste?.(pasteText);
} catch (e) {
Log.e(TAG, 'PasteButton read clipboard error: ' + JSON.stringify(e));
controller.onPaste?.('');
}
} else {
controller.onClose?.();
}
})
Button('取消')
.fontSize(16)
.fontColor('#999999')
.backgroundColor(Color.Transparent)
.onClick(() => {
controller.onClose?.();
})
}
.padding(24)
.backgroundColor(Color.White)
.borderRadius(16)
.width('70%')
.alignItems(HorizontalAlign.Center)
}
// ============================================================
// 自定义 PlatformPluginCallback
// 覆盖剪贴板读取方法,使用 PasteButton 安全控件
// ============================================================
class CustomPlatformPluginCallback extends PlatformPluginCallback {
/**
* 获取剪贴板数据
* 使用 PasteButton 安全控件获取临时授权,替代 READ_PASTEBOARD 权限申请
*/
getClipboardData(result: MethodResult): void {
this.showPasteDialog(result);
}
/**
* 检查剪贴板是否有字符串
* 始终返回 true确保输入框工具栏的粘贴按钮显示
* 实际剪贴板内容在用户点击 PasteButton 后读取
*/
clipboardHasStrings(): boolean {
return true;
}
/**
* 显示粘贴对话框(使用 PasteButton 安全控件)
* 用户点击 PasteButton 后获得临时授权,可静默读取剪贴板数据
*/
private showPasteDialog(result: MethodResult): void {
try {
const win = this.lastWindow ?? this.mainWindow;
if (!win) {
Log.e(TAG, 'No window available for paste dialog');
result.success(null);
return;
}
const uiContext = win.getUIContext();
const promptAction = uiContext.getPromptAction();
const controller = new PasteDialogController();
let isResolved: boolean = false;
let dialogContent: ComponentContent<PasteDialogController> | null = null;
const closeDialog = () => {
if (isResolved) return;
isResolved = true;
if (dialogContent) {
try {
promptAction.closeCustomDialog(dialogContent);
} catch (e) {
// ignore close error
}
}
};
controller.onPaste = (text: string) => {
closeDialog();
const response: Any = new Map().set('text', text);
result.success(response);
};
controller.onClose = () => {
closeDialog();
result.success(null);
};
dialogContent = new ComponentContent(
uiContext,
wrapBuilder(PasteDialogContent),
controller
);
promptAction.openCustomDialog(dialogContent, {
alignment: DialogAlignment.Center,
autoCancel: true,
onWillDismiss: (dialog: DismissDialogAction) => {
if (!isResolved) {
isResolved = true;
result.success(null);
}
dialog.dismiss();
},
}).catch((e: BusinessError) => {
Log.e(TAG, 'Failed to show paste dialog: ' + JSON.stringify(e));
if (!isResolved) {
isResolved = true;
result.success(null);
}
});
} catch (e) {
Log.e(TAG, 'showPasteDialog error: ' + JSON.stringify(e));
result.success(null);
}
}
}
// ============================================================
// 自定义 PlatformPlugin
// 继承原生 PlatformPlugin替换 callback 为自定义实现
// ============================================================
export default class CustomPlatformPlugin extends PlatformPlugin {
constructor(
platformChannel: PlatformChannel,
context: common.Context,
platformPluginDelegate?: PlatformPluginDelegate
) {
super(platformChannel, context, platformPluginDelegate);
// 创建自定义 callback复制原生 callback 的所有属性
const customCallback = new CustomPlatformPluginCallback();
customCallback.platform = this.callback.platform;
customCallback.mainWindow = this.callback.mainWindow;
customCallback.lastWindow = this.callback.lastWindow;
customCallback.platformChannel = this.callback.platformChannel;
customCallback.platformPluginDelegate = this.callback.platformPluginDelegate;
customCallback.context = this.callback.context;
customCallback.uiAbilityContext = this.callback.uiAbilityContext;
customCallback.applicationContext = this.callback.applicationContext;
customCallback.flutterView = this.callback.flutterView;
customCallback.showBarOrNavigation = this.callback.showBarOrNavigation;
customCallback.currentTheme = this.callback.currentTheme;
customCallback.callbackId = this.callback.callbackId;
// 替换 callback 并重新注册到 PlatformChannel
this.callback = customCallback;
customCallback.platformChannel?.setPlatformMessageHandler(customCallback);
Log.i(TAG, 'CustomPlatformPlugin initialized with PasteButton support');
}
}

View File

@@ -1,3 +1,15 @@
// ============================================================
// 闲言APP — 鸿蒙端 EntryAbility
// 创建时间: 2026-04-20
// 更新时间: 2026-06-21
// 作用: Flutter引擎配置 + 自定义剪贴板通道 + 快捷操作通道
// 上次更新: 1. 移除 flutter/platform 通道拦截器,避免覆盖引擎原生
// Clipboard/SystemChrome.setPreferredOrientations 实现
// 2. 覆盖 providePlatformPlugin 返回 CustomPlatformPlugin
// 使用 PasteButton 安全控件替代 READ_PASTEBOARD 系统权限
// 3. 不修改 SDK 源码通过中间件继承实现SDK 升级不受影响
// ============================================================
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import Want from '@ohos.app.ability.Want';
@@ -7,25 +19,36 @@ import MethodCall from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodCall
import { MethodResult } from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodChannel';
import { pasteboard } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { componentUtils } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import PlatformPlugin from '@ohos/flutter_ohos/src/main/ets/plugin/PlatformPlugin';
import CustomPlatformPlugin from './CustomPlatformPlugin';
// 自定义剪贴板通道ClipboardBridge使用
// 自定义剪贴板通道ClipboardBridge 使用,提供同步读写接口
const CLIPBOARD_CHANNEL = 'plugins.flutter.io/clipboard_ohos';
// 快捷操作通道
const QUICK_ACTIONS_CHANNEL = 'plugins.flutter.io/quick_actions_ohos';
// Flutter标准平台通道TextInputPlugin粘贴使用此通道的Clipboard方法
const FLUTTER_PLATFORM_CHANNEL = 'flutter/platform';
export default class EntryAbility extends FlutterAbility {
private clipboardChannel: MethodChannel | null = null;
private platformChannel: MethodChannel | null = null;
/**
* 覆盖 providePlatformPlugin返回自定义 PlatformPlugin
* 使用 PasteButton 安全控件替代 READ_PASTEBOARD 系统权限
* 不修改 SDK 源码,通过继承实现剪贴板读取拦截
*/
providePlatformPlugin(flutterEngine: FlutterEngine): PlatformPlugin | undefined {
return new CustomPlatformPlugin(
flutterEngine.getPlatformChannel()!,
this.context,
this
);
}
configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine)
// 注册自定义剪贴板通道ClipboardBridge使用
// 注册自定义剪贴板通道ClipboardBridge 使用)
// 注意: 此通道独立于 flutter/platform不会覆盖引擎原生 Clipboard 实现
this.clipboardChannel = new MethodChannel(flutterEngine.dartExecutor.getBinaryMessenger(), CLIPBOARD_CHANNEL);
this.clipboardChannel.setMethodCallHandler({
onMethodCall: (call: MethodCall, result: MethodResult): void => {
@@ -46,31 +69,6 @@ export default class EntryAbility extends FlutterAbility {
}
});
// 注册Flutter标准平台通道的剪贴板方法拦截器
// 鸿蒙端Flutter引擎的C++层未实现Clipboard方法导致TextInputPlugin
// 长按粘贴无反应。此处拦截Clipboard方法并路由到鸿蒙pasteboard API
// 非Clipboard方法调用result.notImplemented()交由C++层默认处理。
this.platformChannel = new MethodChannel(flutterEngine.dartExecutor.getBinaryMessenger(), FLUTTER_PLATFORM_CHANNEL);
this.platformChannel.setMethodCallHandler({
onMethodCall: (call: MethodCall, result: MethodResult): void => {
switch (call.method) {
case 'Clipboard.getData':
this.getClipboardDataForPlatform(result);
break;
case 'Clipboard.setData':
this.setClipboardDataForPlatform(call, result);
break;
case 'Clipboard.hasStrings':
this.hasClipboardStringsForPlatform(result);
break;
default:
// 非Clipboard方法交由Flutter引擎C++层默认处理
result.notImplemented();
break;
}
}
});
// 冷启动时检查待处理的快捷方式
if (this.pendingShortcutType && this.pendingShortcutType.length > 0) {
this.notifyShortcutAction(this.pendingShortcutType);
@@ -79,7 +77,7 @@ export default class EntryAbility extends FlutterAbility {
}
// ============================================================
// 自定义通道方法ClipboardBridge使用
// 自定义通道方法ClipboardBridge 使用)
// ============================================================
/// 读取剪贴板文本(返回纯文本字符串)
@@ -100,7 +98,7 @@ export default class EntryAbility extends FlutterAbility {
}
}
/// 检查剪贴板是否有文本返回boolean
/// 检查剪贴板是否有文本(返回 boolean
private hasClipboardStrings(result: MethodResult): void {
try {
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
@@ -126,55 +124,6 @@ export default class EntryAbility extends FlutterAbility {
}
}
// ============================================================
// Flutter标准平台通道方法TextInputPlugin粘贴使用
// 返回格式需符合Flutter Clipboard类期望
// ============================================================
/// Flutter标准Clipboard.getData — 返回 {text: string}
private getClipboardDataForPlatform(result: MethodResult): void {
try {
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
if (pasteData && pasteData.getRecordCount() > 0) {
const record = pasteData.getRecordAt(0);
const text = record.plainText;
result.success({ 'text': text ?? '' });
} else {
result.success({ 'text': '' });
}
} catch (e) {
const err = e as BusinessError;
console.error(`Platform Clipboard.getData error: ${err.code} ${err.message}`);
result.success({ 'text': '' });
}
}
/// Flutter标准Clipboard.setData
private setClipboardDataForPlatform(call: MethodCall, result: MethodResult): void {
try {
const args = call.args as Record<string, string>;
const text = args['text'] ?? '';
const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
pasteboard.getSystemPasteboard().setDataSync(pasteData);
result.success(null);
} catch (e) {
const err = e as BusinessError;
console.error(`Platform Clipboard.setData error: ${err.code} ${err.message}`);
result.success(null);
}
}
/// Flutter标准Clipboard.hasStrings — 返回 {value: boolean}
private hasClipboardStringsForPlatform(result: MethodResult): void {
try {
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
const hasStrings = pasteData && pasteData.hasType(pasteboard.MIMETYPE_TEXT_PLAIN);
result.success({ 'value': hasStrings });
} catch (e) {
result.success({ 'value': false });
}
}
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
super.onCreate(want, launchParam);
const uri = want?.parameters?.['uri'] as string ?? want?.uri;

View File

@@ -62,6 +62,10 @@
"name": "permission_pasteboard_reason",
"value": "Used for clipboard paste in text input"
},
{
"name": "permission_read_pasteboard_reason",
"value": "Used for clipboard paste in text input"
},
{
"name": "permission_bundle_info_reason",
"value": "Used to get application package information"

View File

@@ -39,6 +39,10 @@
{
"name": "shortcut_general_settings_label",
"value": "Settings"
},
{
"name": "permission_read_pasteboard_reason",
"value": "Used for clipboard paste in text input"
}
]
}

View File

@@ -62,6 +62,10 @@
"name": "permission_pasteboard_reason",
"value": "用于文本输入框的剪贴板粘贴"
},
{
"name": "permission_read_pasteboard_reason",
"value": "用于文本输入框的剪贴板粘贴"
},
{
"name": "permission_bundle_info_reason",
"value": "用于获取应用包信息"

View File

@@ -1,9 +1,9 @@
# ============================================================
# 闲言APP (Xianyan) — MacBook Pro端 pubspec 模板
# 创建时间: 2026-06-02
# 更新时间: 2026-06-15
# 更新时间: 2026-06-18
# 作用: MacBook Pro端(iOS/macOS)依赖与资源配置模板(使用远程版本号)
# 上次更新: 同步pubspec.yaml依赖升级 + 删除custom_lint/riverpod_lint + 新增analyzer/test_api/test overrides
# 上次更新: 新增桌面端增强库(tray_manager/macos_window_utils/flutter_acrylic)
# 使用方式:
# ⚠️ 此文件为模板,不要直接重命名为 pubspec.yaml 使用
# ⚠️ 新增三方库时,必须同步更新 pubspec.ohos.yaml 和 pubspec.macos.yaml
@@ -136,7 +136,7 @@ dependencies:
image: ^4.9.0 # 图片解码/编码/变换
# --- 图片编辑器 ---
pro_image_editor: ^12.5.0 # 图片编辑器核心(官方版)
pro_image_editor: 12.4.4 # v12.4.4 | 图片编辑器核心(12.5.x与Flutter 3.33运行时不兼容)
# --- 桌面端增强 ---
desktop_drop: ^0.7.0 # 桌面端文件拖放接收

View File

@@ -157,7 +157,7 @@ dependencies:
image: ^4.9.0 # 图片解码/编码/变换
# --- 图片编辑器 ---
pro_image_editor: ^12.5.0 # v12.5.0 | 图片编辑器核心(官方版)
pro_image_editor: 12.4.4 # v12.4.4 | 图片编辑器核心(12.5.x与Flutter 3.33运行时不兼容)
# --- 桌面端增强 ---
desktop_drop: ^0.7.0 # 桌面端文件拖放接收

File diff suppressed because it is too large Load Diff

View File

@@ -116,3 +116,8 @@ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/local_packages/sqlite3/prebuilt/sqlite3.dll"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Install app_icon.ico for system tray (tray_manager on Windows only supports .ico files)
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/runner/resources/app_icon.ico"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)

View File

@@ -33,7 +33,7 @@ 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_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib" "comctl32.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
# Run the Flutter tool portions of the build. This must not be removed.

View File

@@ -1,12 +1,84 @@
#include "flutter_window.h"
#include <optional>
#include <commctrl.h>
#include <chrono>
#include <fstream>
#include <mutex>
#include <sstream>
#include <string>
#include <flutter/method_channel.h>
#include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h"
// GET_X_LPARAM / GET_Y_LPARAM from windowsx.h
// Defined inline to avoid potential macro conflicts with plugin code
#ifndef GET_X_LPARAM
#define GET_X_LPARAM(lp) ((int)(short)LOWORD(lp))
#endif
#ifndef GET_Y_LPARAM
#define GET_Y_LPARAM(lp) ((int)(short)HIWORD(lp))
#endif
// ============================================================
// debug instrumentation for window-drag-lag (flutter_window side)
// ============================================================
namespace {
std::mutex g_debug_log_mutex2;
std::wstring g_debug_log_path2;
void DebugLog(const std::string& tag, const std::string& detail) {
// 已禁用文件日志以排除 I/O 对消息循环的干扰。
(void)tag;
(void)detail;
#if 0
auto now = std::chrono::system_clock::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
now.time_since_epoch())
.count();
std::lock_guard<std::mutex> lock(g_debug_log_mutex2);
if (g_debug_log_path2.empty()) {
g_debug_log_path2 = L"E:\\project\\flutter\\f\\xianyan\\xianyan_drag_debug.log";
}
std::wofstream ofs(g_debug_log_path2, std::ios::app);
if (!ofs.is_open()) return;
ofs << us << L" [" << std::wstring(tag.begin(), tag.end()) << L"] "
<< std::wstring(detail.begin(), detail.end()) << L"\n";
#endif
}
std::string MsgName(UINT msg) {
switch (msg) {
case WM_NCHITTEST: return "WM_NCHITTEST";
case WM_NCLBUTTONDOWN: return "WM_NCLBUTTONDOWN";
case WM_NCLBUTTONUP: return "WM_NCLBUTTONUP";
case WM_NCMOUSEMOVE: return "WM_NCMOUSEMOVE";
case WM_LBUTTONDOWN: return "WM_LBUTTONDOWN";
case WM_LBUTTONUP: return "WM_LBUTTONUP";
case WM_MOUSEMOVE: return "WM_MOUSEMOVE";
case WM_MOVE: return "WM_MOVE";
case WM_MOVING: return "WM_MOVING";
case WM_SIZE: return "WM_SIZE";
case WM_WINDOWPOSCHANGING: return "WM_WINDOWPOSCHANGING";
case WM_WINDOWPOSCHANGED: return "WM_WINDOWPOSCHANGED";
case WM_SYSCOMMAND: return "WM_SYSCOMMAND";
case WM_ENTERSIZEMOVE: return "WM_ENTERSIZEMOVE";
case WM_EXITSIZEMOVE: return "WM_EXITSIZEMOVE";
case WM_PAINT: return "WM_PAINT";
case WM_ERASEBKGND: return "WM_ERASEBKGND";
default: return "MSG_" + std::to_string(msg);
}
}
} // namespace
// ============================================================
// 静态成员初始化
// ============================================================
bool FlutterWindow::is_in_native_drag_ = false;
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {}
@@ -30,6 +102,18 @@ bool FlutterWindow::OnCreate() {
RegisterPlugins(flutter_controller_->engine());
SetChildContent(flutter_controller_->view()->GetNativeWindow());
// ============================================================
// 子类化 Flutter 子窗口:标题栏命中测试穿透
// Flutter 子窗口覆盖整个客户区,拦截了所有鼠标消息。
// 通过子类化,在标题栏可拖拽区域返回 HTTRANSPARENT让父窗口
// 的 WM_NCHITTEST 有机会返回 HTCAPTION从而触发 Windows 原生
// 模态拖拽循环。原生拖拽由 DWM 直接处理,延迟最低。
// 标题栏右侧控制按钮区域返回 HTCLIENT保证按钮可点击。
// ============================================================
HWND child_hwnd = flutter_controller_->view()->GetNativeWindow();
SetWindowSubclass(child_hwnd, ChildWndProc, kChildSubclassId,
reinterpret_cast<DWORD_PTR>(GetHandle()));
flutter_controller_->engine()->SetNextFrameCallback([&]() {
this->Show();
});
@@ -62,7 +146,6 @@ bool FlutterWindow::OnCreate() {
Win32Window::SetDarkMode(GetHandle(), is_dark);
result->Success();
} else if (method == "setWindowTitle") {
// 设置窗口标题
std::string title;
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
auto it = args->find(flutter::EncodableValue("title"));
@@ -74,7 +157,6 @@ bool FlutterWindow::OnCreate() {
Win32Window::SetWindowTitle(GetHandle(), wide_title);
result->Success();
} else if (method == "setFullscreen") {
// 进入/退出全屏
bool fullscreen = false;
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
auto it = args->find(flutter::EncodableValue("fullscreen"));
@@ -85,11 +167,9 @@ bool FlutterWindow::OnCreate() {
Win32Window::SetFullscreen(GetHandle(), fullscreen);
result->Success();
} else if (method == "isFullscreen") {
// 查询全屏状态
bool is_fullscreen = Win32Window::IsFullscreen(GetHandle());
result->Success(flutter::EncodableValue(is_fullscreen));
} else if (method == "setMinSize") {
// 设置最小尺寸
unsigned int width = 0;
unsigned int height = 0;
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
@@ -105,7 +185,6 @@ bool FlutterWindow::OnCreate() {
Win32Window::SetMinSize(GetHandle(), width, height);
result->Success();
} else if (method == "performHapticFeedback") {
// 触觉反馈Windows 用 MessageBeep 模拟)
int feedback_type = 0;
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
auto it = args->find(flutter::EncodableValue("type"));
@@ -116,7 +195,6 @@ bool FlutterWindow::OnCreate() {
Win32Window::PerformHapticFeedback(GetHandle(), feedback_type);
result->Success();
} else if (method == "getSystemAppearance") {
// 获取系统外观模式
std::string appearance = Win32Window::GetSystemAppearance();
result->Success(flutter::EncodableValue(appearance));
} else {
@@ -124,21 +202,387 @@ bool FlutterWindow::OnCreate() {
}
});
// ============================================================
// 窗口控制 MethodChannel窗口大小预设菜单等
// ============================================================
window_control_channel_ = std::make_unique<flutter::MethodChannel<>>(
flutter_controller_->engine()->messenger(), "xianyan/window_control",
&flutter::StandardMethodCodec::GetInstance());
window_control_channel_->SetMethodCallHandler(
[this](const flutter::MethodCall<>& call,
std::unique_ptr<flutter::MethodResult<>> result) {
const std::string& method = call.method_name();
if (method == "showWindowSizeMenu") {
// 使用 PostMessage 异步弹出菜单,避免 TrackPopupMenuEx
// 阻塞 MethodChannel 回调线程Flutter UI 线程)
PostMessage(GetHandle(), WM_USER + 1000, 0, 0);
result->Success(flutter::EncodableValue(true));
} else {
result->NotImplemented();
}
});
return true;
}
void FlutterWindow::OnDestroy() {
if (flutter_controller_) {
HWND child_hwnd = flutter_controller_->view()->GetNativeWindow();
RemoveWindowSubclass(child_hwnd, ChildWndProc, kChildSubclassId);
flutter_controller_ = nullptr;
}
Win32Window::OnDestroy();
}
// ============================================================
// 原生窗口大小预设菜单
//
// 由于 Mica/Acrylic 导致 DefWindowProc 边框命中测试失效,
// 手动实现的边框命中测试在某些环境下仍无法调整窗口大小。
// 这里提供点击"口"按钮弹出原生菜单选择窗口大小的替代方案。
//
// 使用 Win32 TrackPopupMenuEx 弹出原生菜单,符合 Windows 原生风格。
// 菜单项包括:最大化/还原、几种预设尺寸。
// ============================================================
bool FlutterWindow::ShowWindowSizeMenu(HWND hwnd) {
if (!hwnd) return false;
// 创建弹出菜单
HMENU hMenu = CreatePopupMenu();
if (!hMenu) return false;
// 检查当前是否最大化
bool is_maximized = (::IsZoomed(hwnd) != 0);
// 菜单项 ID从 1001 开始,避免与系统命令冲突)
enum MenuId {
ID_TOGGLE_MAXIMIZE = 1001,
ID_SIZE_800x600,
ID_SIZE_1024x768,
ID_SIZE_1280x720,
ID_SIZE_1440x900,
ID_SIZE_1920x1080,
};
// 添加菜单项
AppendMenuW(hMenu, MF_STRING,
ID_TOGGLE_MAXIMIZE,
is_maximized ? L"🔽 还原窗口" : L"⬜ 最大化");
AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(hMenu, MF_STRING, ID_SIZE_800x600, L"📱 小窗 800 × 600");
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1024x768, L"💻 标准 1024 × 768");
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1280x720, L"🖥️ 宽屏 1280 × 720");
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1440x900, L"🖥️ 大屏 1440 × 900");
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1920x1080, L"📺 全高清 1920 × 1080");
// 获取鼠标位置(菜单显示在鼠标位置)
POINT cursor_pos;
GetCursorPos(&cursor_pos);
// 设置菜单为右对齐、右键选择
UINT flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_LEFTBUTTON |
TPM_RIGHTBUTTON | TPM_RETURNCMD;
// 弹出菜单(同步阻塞,返回选择的菜单项 ID0 表示取消)
int cmd = TrackPopupMenuEx(hMenu, flags, cursor_pos.x, cursor_pos.y, hwnd,
nullptr);
DestroyMenu(hMenu);
if (cmd == 0) return false; // 用户取消
// 处理用户选择
int width = 0, height = 0;
switch (cmd) {
case ID_TOGGLE_MAXIMIZE:
if (is_maximized) {
PostMessage(hwnd, WM_SYSCOMMAND, SC_RESTORE, 0);
} else {
PostMessage(hwnd, WM_SYSCOMMAND, SC_MAXIMIZE, 0);
}
return true;
case ID_SIZE_800x600: width = 800; height = 600; break;
case ID_SIZE_1024x768: width = 1024; height = 768; break;
case ID_SIZE_1280x720: width = 1280; height = 720; break;
case ID_SIZE_1440x900: width = 1440; height = 900; break;
case ID_SIZE_1920x1080: width = 1920; height = 1080; break;
default: return false;
}
// 取消最大化(如果当前是最大化状态)
if (is_maximized) {
ShowWindow(hwnd, SW_RESTORE);
}
// 获取窗口当前位置,保持左上角不变
RECT wr;
GetWindowRect(hwnd, &wr);
// 调整为指定大小(考虑 DPI 缩放)
UINT dpi = GetDpiForWindow(hwnd);
int physical_width = static_cast<int>(width * dpi / 96.0);
int physical_height = static_cast<int>(height * dpi / 96.0);
SetWindowPos(hwnd, nullptr, wr.left, wr.top,
physical_width, physical_height,
SWP_NOZORDER | SWP_NOACTIVATE);
return true;
}
// ============================================================
// Flutter 子窗口子类化回调
//
// 核心思路:
// 1. 标题栏可拖拽区域返回 HTTRANSPARENT让父窗口的 WM_NCHITTEST
// 返回 HTCAPTIONWindows 随后进入原生模态拖拽循环。
// 2. 标题栏右侧控制按钮区域返回 HTCLIENT保证 Flutter 按钮可点击。
// 3. 其他区域返回 DefSubclassProc由 Flutter 正常处理。
//
// 注意:窗口边框缩放由父窗口原生非客户区处理,不需要子窗口干预。
// ============================================================
LRESULT CALLBACK FlutterWindow::ChildWndProc(
HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam,
UINT_PTR subclass_id, DWORD_PTR ref_data) {
HWND top_hwnd = reinterpret_cast<HWND>(ref_data);
// ============================================================
// 拖拽期间:拦截 Flutter 子窗口的所有消息(除了 WM_NCHITTEST
//
// Flutter 子窗口覆盖整个客户区,在拖拽期间会持续接收消息
// WM_PAINT/WM_MOVE/WM_SIZE/WM_WINDOWPOSCHANGED 等)并触发
// Flutter 引擎重绘,阻塞 Win32 消息循环(= UI 线程),导致
// DWM 无法应用 live-drag 优化。
//
// 拖拽期间所有消息直接交给 DefWindowProc不经过 Flutter 引擎的
// WndProc阻止 Flutter 重绘。WM_NCHITTEST 仍需正常处理,
// 否则鼠标在标题栏的命中测试会失败。
// ============================================================
if (is_in_native_drag_ && message != WM_NCHITTEST) {
return DefWindowProc(hwnd, message, wparam, lparam);
}
// region debug-point child-msg
if (message == WM_NCHITTEST || message == WM_NCLBUTTONDOWN ||
message == WM_NCLBUTTONUP || message == WM_NCMOUSEMOVE ||
message == WM_LBUTTONDOWN || message == WM_LBUTTONUP ||
message == WM_MOUSEMOVE || message == WM_MOVE || message == WM_MOVING ||
message == WM_SIZE || message == WM_WINDOWPOSCHANGING ||
message == WM_WINDOWPOSCHANGED || message == WM_SYSCOMMAND ||
message == WM_ENTERSIZEMOVE || message == WM_EXITSIZEMOVE ||
message == WM_PAINT || message == WM_ERASEBKGND) {
std::ostringstream oss;
oss << "hwnd=" << hwnd << " msg=" << MsgName(message)
<< " wp=" << wparam << " lp=" << lparam;
DebugLog("child", oss.str());
}
// endregion debug-point child-msg
switch (message) {
case WM_NCHITTEST: {
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
ScreenToClient(hwnd, &pt);
UINT dpi = GetDpiForWindow(top_hwnd);
double scale = static_cast<double>(dpi) / 96.0;
int title_h = static_cast<int>(36 * scale);
int btn_w = static_cast<int>(138 * scale);
RECT cr;
GetClientRect(top_hwnd, &cr);
{
std::ostringstream oss;
oss << "WM_NCHITTEST raw_screen=(" << GET_X_LPARAM(lparam) << ","
<< GET_Y_LPARAM(lparam) << ") client=(" << pt.x << "," << pt.y
<< ") dpi=" << dpi << " scale=" << scale
<< " title_h=" << title_h << " btn_w=" << btn_w
<< " cr=(" << cr.right << "," << cr.bottom << ")";
DebugLog("child", oss.str());
}
if (cr.right <= 0 || cr.bottom <= 0) {
DebugLog("child", "WM_NCHITTEST fallback: empty client rect");
return DefSubclassProc(hwnd, message, wparam, lparam);
}
// 标题栏可拖拽区域:穿透到父窗口,父窗口会返回 HTCAPTION
if (pt.y >= 0 && pt.y < title_h &&
pt.x >= 0 && pt.x < cr.right - btn_w) {
std::ostringstream oss;
oss << "WM_NCHITTEST -> HTTRANSPARENT pt=(" << pt.x << "," << pt.y
<< ")";
DebugLog("child", oss.str());
return HTTRANSPARENT;
}
// 标题栏右侧控制按钮区域:保持 HTCLIENT让 Flutter 处理点击
if (pt.y >= 0 && pt.y < title_h &&
pt.x >= cr.right - btn_w && pt.x <= cr.right) {
DebugLog("child", "WM_NCHITTEST -> HTCLIENT (buttons)");
return HTCLIENT;
}
DebugLog("child", "WM_NCHITTEST -> DefSubclassProc");
return DefSubclassProc(hwnd, message, wparam, lparam);
}
}
return DefSubclassProc(hwnd, message, wparam, lparam);
}
LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// ============================================================
// 异步弹出窗口大小预设菜单
// MethodChannel 回调中通过 PostMessage 触发,避免 TrackPopupMenuEx
// 阻塞 Flutter UI 线程
// ============================================================
if (message == WM_USER + 1000) {
ShowWindowSizeMenu(hwnd);
return 0;
}
// ============================================================
// 拖拽卡顿修复 v9WM_NCLBUTTONDOWN 时禁用 Mica + SetWindowCompositionAttribute
//
// 根因链(经 v1~v8 验证):
// 1. 手动 SetWindowPos 拖拽不触发 DWM "live drag" 优化
// 2. 完全禁用 Mica 能解决卡顿v8 验证),但临时禁用 Micav4~v7失败
// 3. v4~v7 失败的原因EnterSizeMove 遗漏了 SetWindowCompositionAttribute
// (ACCENT_DISABLED) 调用——这是 flutter_acrylic 的 setEffect(disabled)
// 能立即禁用 Mica 的关键。仅靠 DwmSetWindowAttribute 是异步的,不生效。
//
// v9 修复:
// - WM_NCLBUTTONDOWN(HTCAPTION) 时(拖拽还没开始)调用 EnterSizeMove 禁用 Mica
// (现在包含 SetWindowCompositionAttribute(ACCENT_DISABLED),能立即生效)
// - SWP_FRAMECHANGED 强制 DWM 同步应用变更
// - DefWindowProc 进入 SC_MOVE 模态循环
// - DefWindowProc 返回后(拖拽已结束)调用 ExitSizeMove 恢复 Mica
// - 模态循环期间绕过插件链 + 子窗口消息拦截v8
// ============================================================
// --- 标题栏 + 边框命中测试:直接返回,绕过插件 ---
// flutter_acrylic 的 DwmExtendFrameIntoClientArea({-1,-1,-1,-1}) 和
// window_manager 的 TitleBarStyle.hidden 会导致 DefWindowProc 的边框
// 命中测试失效。这里手动实现边框命中测试,恢复窗口边缘调整大小功能。
if (message == WM_NCHITTEST) {
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
UINT dpi = GetDpiForWindow(hwnd);
double scale = static_cast<double>(dpi) / 96.0;
int title_h = static_cast<int>(36.0 * scale);
int btn_w = static_cast<int>(138.0 * scale);
// 边框命中测试宽度(稍大于实际边框,方便用户操作)
int border_w = static_cast<int>(6.0 * scale);
// 使用窗口坐标(而非客户区坐标),避免客户区边距导致的负坐标问题
RECT wr;
GetWindowRect(hwnd, &wr);
bool maximized = (::IsZoomed(hwnd) != 0);
if (!maximized) {
bool on_left = pt.x >= wr.left && pt.x < wr.left + border_w;
bool on_right = pt.x >= wr.right - border_w && pt.x < wr.right;
bool on_top = pt.y >= wr.top && pt.y < wr.top + border_w;
bool on_bottom = pt.y >= wr.bottom - border_w && pt.y < wr.bottom;
if (on_top && on_left) return HTTOPLEFT;
if (on_top && on_right) return HTTOPRIGHT;
if (on_bottom && on_left) return HTBOTTOMLEFT;
if (on_bottom && on_right) return HTBOTTOMRIGHT;
if (on_left) return HTLEFT;
if (on_right) return HTRIGHT;
if (on_top) return HTTOP;
if (on_bottom) return HTBOTTOM;
}
// 标题栏拖拽区域(使用客户区坐标判断)
POINT client_pt = pt;
ScreenToClient(hwnd, &client_pt);
RECT cr;
GetClientRect(hwnd, &cr);
if (cr.right > 0 && cr.bottom > 0) {
if (client_pt.y >= 0 && client_pt.y < title_h &&
client_pt.x >= 0 && client_pt.x < cr.right - btn_w) {
return HTCAPTION;
}
}
// 非标题栏/边框区域:继续交给插件处理(返回 HTCLIENT
}
// --- 标题栏拖拽启动:提前禁用 Mica再进入原生 SC_MOVE 模态循环 ---
// 关键:在 WM_NCLBUTTONDOWN 时(拖拽还没开始)禁用 Mica。
// EnterSizeMove 现在包含 SetWindowCompositionAttribute(ACCENT_DISABLED)
// 能立即禁用 Mica与 flutter_acrylic 的 setEffect(disabled) 一致)。
if (message == WM_NCLBUTTONDOWN && wparam == HTCAPTION) {
// 1. 拖拽开始前禁用 Mica/Acrylic backdrop立即生效
EnterSizeMove(hwnd);
// 2. SWP_FRAMECHANGED 触发 WM_NCCALCSIZE强制 DWM 同步应用变更
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
SWP_NOZORDER | SWP_NOACTIVATE);
// 3. 进入原生 SC_MOVE 模态循环(同步阻塞,返回时拖拽已结束)
LRESULT drag_result = DefWindowProc(hwnd, message, wparam, lparam);
// 4. 拖拽结束后恢复 Mica/Acrylic backdrop
ExitSizeMove(hwnd);
// 5. SWP_FRAMECHANGED 强制 DWM 同步应用 Mica 恢复
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
SWP_NOZORDER | SWP_NOACTIVATE);
return drag_result;
}
// --- 原生拖拽/调整大小模态循环开始 ---
// 无论是拖拽标题栏(已在 WM_NCLBUTTONDOWN 提前禁用 Mica还是从边框调整大小
// 都会触发 WM_ENTERSIZEMOVE。对于边框调整大小的情况这里禁用 Mica。
if (message == WM_ENTERSIZEMOVE) {
is_in_native_drag_ = true;
// 如果还没有禁用 Mica边框调整大小的情况现在禁用
if (!is_in_size_move_loop_) {
EnterSizeMove(hwnd);
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
SWP_NOZORDER | SWP_NOACTIVATE);
}
return DefWindowProc(hwnd, message, wparam, lparam);
}
// --- 原生拖拽/调整大小模态循环结束 ---
if (message == WM_EXITSIZEMOVE) {
is_in_native_drag_ = false;
// 如果之前禁用了 Mica边框调整大小的情况现在恢复
if (is_in_size_move_loop_) {
ExitSizeMove(hwnd);
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
SWP_NOZORDER | SWP_NOACTIVATE);
}
return DefWindowProc(hwnd, message, wparam, lparam);
}
// ============================================================
// 原生拖拽期间:完全绕过插件链
//
// window_manager 插件在 WM_MOVING 中调用 _EmitEvent("move")
// 通过 MethodChannel 通知 Dart 侧。Dart 侧的 listener 可能触发
// setState → Flutter 重建 → 阻塞 UI 线程(= Win32 消息循环线程)。
// 22 个插件的 delegate 分发也会累积延迟。
//
// 拖拽期间所有消息直接交给 DefWindowProc让 DWM live-drag 优化
// 不受任何干扰。同时 ChildWndProc 也会拦截 Flutter 子窗口的消息,
// 阻止 Flutter 引擎重绘。此时 Mica 已被 EnterSizeMove 禁用。
// ============================================================
if (is_in_native_drag_) {
return DefWindowProc(hwnd, message, wparam, lparam);
}
// --- 非拖拽期间:正常消息流转 ---
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result =

View File

@@ -27,11 +27,48 @@ class FlutterWindow : public Win32Window {
// The project to run.
flutter::DartProject project_;
// The Flutter instance hosted by this window.
// The Flutter instance hosted in this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
// Windows platform MethodChannel for theme control.
std::unique_ptr<flutter::MethodChannel<>> platform_channel_;
// 窗口控制 MethodChannel窗口大小预设菜单等
std::unique_ptr<flutter::MethodChannel<>> window_control_channel_;
// 弹出原生窗口大小预设菜单TrackPopupMenuEx
// 返回 true 表示用户选择了某个尺寸false 表示用户取消了菜单
bool ShowWindowSizeMenu(HWND hwnd);
// Subclass ID for the Flutter child window (WM_NCHITTEST → HTTRANSPARENT)
static const UINT_PTR kChildSubclassId = 12345;
// Subclass callback: forward title-bar hit-test to parent window so the
// parent can return HTCAPTION and Windows performs native modal dragging.
static LRESULT CALLBACK ChildWndProc(HWND hwnd, UINT message,
WPARAM wparam, LPARAM lparam,
UINT_PTR subclass_id,
DWORD_PTR ref_data);
// ============================================================
// 原生拖拽状态SC_MOVE 模态循环期间绕过插件链 + 子窗口消息拦截
//
// 根因(经 v1~v8 验证):
// 1. 手动 SetWindowPos 拖拽不触发 DWM "live drag" 优化
// 2. 完全禁用 Mica 能解决卡顿v8 验证),但临时禁用 Micav4~v7失败
// 3. v4~v7 失败的原因EnterSizeMove 遗漏了 SetWindowCompositionAttribute
// (ACCENT_DISABLED) 调用——这是 flutter_acrylic 的 setEffect(disabled)
// 能立即禁用 Mica 的关键。仅靠 DwmSetWindowAttribute 是异步的,不生效。
//
// 修复v9
// - WM_NCLBUTTONDOWN(HTCAPTION) 时调用 EnterSizeMove 禁用 Mica
// (现在包含 SetWindowCompositionAttribute(ACCENT_DISABLED),能立即生效)
// - SWP_FRAMECHANGED 强制 DWM 同步应用变更
// - DefWindowProc 进入 SC_MOVE 模态循环
// - DefWindowProc 返回后调用 ExitSizeMove 恢复 Mica + SWP_FRAMECHANGED
// - 模态循环期间绕过插件链 + 子窗口消息拦截v8
// ============================================================
static bool is_in_native_drag_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_

View File

@@ -3,9 +3,132 @@
#include <dwmapi.h>
#include <flutter_windows.h>
#include <shellscalingapi.h>
#include <windowsx.h>
#include <chrono>
#include <fstream>
#include <iomanip>
#include <mutex>
#include <sstream>
#include <string>
#include "resource.h"
// ============================================================
// WindowCompositionAttribute 相关定义(与 flutter_acrylic 一致)
// 用于调用 user32.dll 的未公开 API SetWindowCompositionAttribute
// 这是 flutter_acrylic 禁用 Mica/Acrylic 的关键调用
// ============================================================
typedef enum _WCA_WINDOWCOMPOSITIONATTRIB {
WCA_ATTRIB_ACCENT_POLICY = 19,
} WCA_WINDOWCOMPOSITIONATTRIB;
typedef enum _WCA_ACCENT_STATE {
WCA_ACCENT_DISABLED = 0,
WCA_ACCENT_ENABLE_ACRYLICBLURBEHIND = 4,
WCA_ACCENT_ENABLE_HOSTBACKDROP = 5,
} WCA_ACCENT_STATE;
typedef struct _WCA_ACCENT_POLICY {
WCA_ACCENT_STATE AccentState;
DWORD AccentFlags;
DWORD GradientColor;
DWORD AnimationId;
} WCA_ACCENT_POLICY;
typedef struct _WCA_WINDOWCOMPOSITIONATTRIBDATA {
WCA_WINDOWCOMPOSITIONATTRIB Attrib;
PVOID pvData;
SIZE_T cbData;
} WCA_WINDOWCOMPOSITIONATTRIBDATA;
typedef BOOL(WINAPI* WCA_SetWindowCompositionAttribute)(
HWND, WCA_WINDOWCOMPOSITIONATTRIBDATA*);
// ============================================================
// debug instrumentation for window-drag-lag
// ============================================================
namespace {
std::mutex g_debug_log_mutex;
std::wstring g_debug_log_path;
void DebugLog(const std::string& tag, const std::string& detail) {
// 已禁用文件日志以排除 I/O 对消息循环的干扰。
(void)tag;
(void)detail;
#if 0
auto now = std::chrono::system_clock::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
now.time_since_epoch())
.count();
std::lock_guard<std::mutex> lock(g_debug_log_mutex);
if (g_debug_log_path.empty()) {
g_debug_log_path = L"E:\\project\\flutter\\f\\xianyan\\xianyan_drag_debug.log";
}
std::wofstream ofs(g_debug_log_path, std::ios::app);
if (!ofs.is_open()) return;
ofs << us << L" [" << std::wstring(tag.begin(), tag.end()) << L"] "
<< std::wstring(detail.begin(), detail.end()) << L"\n";
#endif
}
// 仅记录拖拽状态关键事件,验证 EnterSizeMove/ExitSizeMove 是否被调用。
void DragDebugLog(const std::string& line) {
static std::mutex m;
static std::wstring path =
L"E:\\project\\flutter\\f\\xianyan\\xianyan_drag_state.log";
std::lock_guard<std::mutex> lock(m);
std::ofstream ofs(path, std::ios::app);
if (!ofs.is_open()) return;
auto now = std::chrono::system_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch())
.count();
ofs << ms << " " << line << "\n";
}
const char* HitTestName(LRESULT ht) {
switch (ht) {
case HTNOWHERE: return "HTNOWHERE";
case HTCLIENT: return "HTCLIENT";
case HTCAPTION: return "HTCAPTION";
case HTLEFT: return "HTLEFT";
case HTRIGHT: return "HTRIGHT";
case HTTOP: return "HTTOP";
case HTBOTTOM: return "HTBOTTOM";
case HTTOPLEFT: return "HTTOPLEFT";
case HTTOPRIGHT: return "HTTOPRIGHT";
case HTBOTTOMLEFT: return "HTBOTTOMLEFT";
case HTBOTTOMRIGHT: return "HTBOTTOMRIGHT";
case HTTRANSPARENT: return "HTTRANSPARENT";
default: return "OTHER";
}
}
std::string MsgName(UINT msg) {
switch (msg) {
case WM_NCHITTEST: return "WM_NCHITTEST";
case WM_NCLBUTTONDOWN: return "WM_NCLBUTTONDOWN";
case WM_NCLBUTTONUP: return "WM_NCLBUTTONUP";
case WM_NCMOUSEMOVE: return "WM_NCMOUSEMOVE";
case WM_LBUTTONDOWN: return "WM_LBUTTONDOWN";
case WM_LBUTTONUP: return "WM_LBUTTONUP";
case WM_MOUSEMOVE: return "WM_MOUSEMOVE";
case WM_MOVE: return "WM_MOVE";
case WM_MOVING: return "WM_MOVING";
case WM_SIZE: return "WM_SIZE";
case WM_WINDOWPOSCHANGING: return "WM_WINDOWPOSCHANGING";
case WM_WINDOWPOSCHANGED: return "WM_WINDOWPOSCHANGED";
case WM_SYSCOMMAND: return "WM_SYSCOMMAND";
case WM_ENTERSIZEMOVE: return "WM_ENTERSIZEMOVE";
case WM_EXITSIZEMOVE: return "WM_EXITSIZEMOVE";
case WM_PAINT: return "WM_PAINT";
case WM_ERASEBKGND: return "WM_ERASEBKGND";
default: return "MSG_" + std::to_string(msg);
}
}
} // namespace
namespace {
/// Window attribute that enables dark mode window decorations.
@@ -17,6 +140,37 @@ namespace {
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
// DWM 过渡动画与背景材质类型(兼容旧版 SDK
#ifndef DWMWA_TRANSITIONS_FORCEDISABLED
#define DWMWA_TRANSITIONS_FORCEDISABLED 3
#endif
#ifndef DWMWA_SYSTEMBACKDROP_TYPE
#define DWMWA_SYSTEMBACKDROP_TYPE 38
#endif
#ifndef DWMWA_MICA_EFFECT
#define DWMWA_MICA_EFFECT 1029
#endif
// RtlGetVersion 用于获取真实系统版本GetVersionExW 在 Win8.1+ 会返回兼容性版本)
typedef LONG NTSTATUS, *PNTSTATUS;
#define STATUS_SUCCESS (0x00000000)
typedef NTSTATUS(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
#ifndef DWMSBT_AUTO
#define DWMSBT_AUTO 0
#endif
#ifndef DWMSBT_NONE
#define DWMSBT_NONE 1
#endif
#ifndef DWMSBT_MAINWINDOW
#define DWMSBT_MAINWINDOW 3
#endif
#ifndef DWMSBT_TRANSIENTWINDOW
#define DWMSBT_TRANSIENTWINDOW 4
#endif
#ifndef DWMSBT_TABBEDWINDOW
#define DWMSBT_TABBEDWINDOW 2
#endif
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
/// Registry key for app theme preference.
@@ -92,7 +246,7 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() {
WNDCLASS window_class{};
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.style = CS_DBLCLKS;
window_class.cbClsExtra = 0;
window_class.cbWndExtra = 0;
window_class.hInstance = GetModuleHandle(nullptr);
@@ -136,7 +290,8 @@ bool Win32Window::Create(const std::wstring& title,
double scale_factor = dpi / 96.0;
HWND window = CreateWindow(
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
window_class, title.c_str(),
WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN,
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);
@@ -145,6 +300,8 @@ bool Win32Window::Create(const std::wstring& title,
return false;
}
DetectBackdropCapabilities(&system_backdrop_supported_,
&mica_effect_supported_);
UpdateTheme(window);
return OnCreate();
@@ -179,6 +336,22 @@ Win32Window::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// region debug-point parent-msg
if (message == WM_NCHITTEST || message == WM_NCLBUTTONDOWN ||
message == WM_NCLBUTTONUP || message == WM_NCMOUSEMOVE ||
message == WM_LBUTTONDOWN || message == WM_LBUTTONUP ||
message == WM_MOUSEMOVE || message == WM_MOVE || message == WM_MOVING ||
message == WM_SIZE || message == WM_WINDOWPOSCHANGING ||
message == WM_WINDOWPOSCHANGED || message == WM_SYSCOMMAND ||
message == WM_ENTERSIZEMOVE || message == WM_EXITSIZEMOVE ||
message == WM_ERASEBKGND) {
std::ostringstream oss;
oss << "hwnd=" << hwnd << " msg=" << MsgName(message)
<< " wp=" << wparam << " lp=" << lparam;
DebugLog("parent", oss.str());
}
// endregion debug-point parent-msg
switch (message) {
case WM_DESTROY:
window_handle_ = nullptr;
@@ -201,9 +374,11 @@ Win32Window::MessageHandler(HWND hwnd,
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);
// DwmExtendFrameIntoClientArea(margins={-1}) 会改变窗口帧布局,
// 但 GetClientRect 始终返回正确的客户区尺寸(左上角为 0,0
// 所以 MoveWindow 从 (0,0) 开始填充整个客户区是正确的。
// 关键:确保 bRepaint=TRUE 以立即重绘。
MoveWindow(child_content_, 0, 0, rect.right, rect.bottom, TRUE);
}
return 0;
}
@@ -218,6 +393,30 @@ Win32Window::MessageHandler(HWND hwnd,
UpdateTheme(hwnd);
return 0;
case WM_ERASEBKGND: {
UINT64 enter = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
LRESULT r = DefWindowProc(hwnd, message, wparam, lparam);
UINT64 now = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
std::ostringstream oss;
oss << "WM_ERASEBKGND duration_us=" << (now - enter);
DebugLog("parent", oss.str());
return r;
}
case WM_ENTERSIZEMOVE: {
EnterSizeMove(hwnd);
break;
}
case WM_EXITSIZEMOVE: {
ExitSizeMove(hwnd);
break;
}
case WM_GETMINMAXINFO: {
// 处理窗口最小尺寸限制
MINMAXINFO* mmi = reinterpret_cast<MINMAXINFO*>(lparam);
@@ -233,6 +432,61 @@ Win32Window::MessageHandler(HWND hwnd,
}
return 0;
}
case WM_NCHITTEST: {
// 标题栏可拖拽区域返回 HTCAPTION让 Windows 进入原生模态拖拽循环。
// 原生拖拽由 DWM 直接处理,不会经过 Flutter 插件链,避免 MethodChannel
// 和 Dart setState 导致的延迟。
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
ScreenToClient(hwnd, &pt);
UINT dpi = GetDpiForWindow(hwnd);
double scale = static_cast<double>(dpi) / 96.0;
int title_h = static_cast<int>(title_bar_height_ * scale);
int btn_w = static_cast<int>(title_bar_button_width_ * scale);
RECT cr;
GetClientRect(hwnd, &cr);
{
std::ostringstream oss;
oss << "WM_NCHITTEST raw_screen=(" << GET_X_LPARAM(lparam) << ","
<< GET_Y_LPARAM(lparam) << ") client=(" << pt.x << "," << pt.y
<< ") dpi=" << dpi << " scale=" << scale
<< " title_h=" << title_h << " btn_w=" << btn_w
<< " cr=(" << cr.right << "," << cr.bottom << ")";
DebugLog("parent", oss.str());
}
if (cr.right <= 0 || cr.bottom <= 0) {
DebugLog("parent", "WM_NCHITTEST fallback: empty client rect");
break;
}
if (pt.y >= 0 && pt.y < title_h &&
pt.x >= 0 && pt.x < cr.right - btn_w) {
std::ostringstream oss;
oss << "WM_NCHITTEST -> HTCAPTION pt=(" << pt.x << "," << pt.y
<< ") title_h=" << title_h << " btn_w=" << btn_w
<< " cr=(" << cr.right << "," << cr.bottom << ")";
DebugLog("parent", oss.str());
return HTCAPTION;
}
std::ostringstream oss;
oss << "WM_NCHITTEST -> Def pt=(" << pt.x << "," << pt.y
<< ") title_h=" << title_h << " btn_w=" << btn_w;
DebugLog("parent", oss.str());
break;
}
case WM_NCLBUTTONDOWN: {
// 标题栏 HTCAPTION 的拖拽已在 FlutterWindow::MessageHandler 中
// 前置处理(先禁用 DWM 效果再进入原生拖拽循环)。
// 此处仅处理非标题栏区域点击,由 DefWindowProc 处理窗口边框调整大小等。
std::ostringstream oss;
oss << "WM_NCLBUTTONDOWN ht=" << HitTestName(static_cast<LRESULT>(wparam));
DebugLog("parent", oss.str());
break;
}
}
return DefWindowProc(window_handle_, message, wparam, lparam);
@@ -260,8 +514,7 @@ void Win32Window::SetChildContent(HWND content) {
SetParent(content, window_handle_);
RECT frame = GetClientArea();
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
frame.bottom - frame.top, true);
MoveWindow(content, 0, 0, frame.right, frame.bottom, true);
SetFocus(child_content_);
}
@@ -321,6 +574,8 @@ LONG Win32Window::saved_window_ex_style_ = 0;
WINDOWPLACEMENT Win32Window::saved_placement_ = {sizeof(WINDOWPLACEMENT)};
unsigned int Win32Window::min_width_ = 400;
unsigned int Win32Window::min_height_ = 600;
double Win32Window::title_bar_height_ = 36.0;
double Win32Window::title_bar_button_width_ = 138.0;
// ============================================================
// 窗口管理扩展方法实现
@@ -422,3 +677,166 @@ std::string Win32Window::GetSystemAppearance() {
}
return "light";
}
// ============================================================
// 拖拽时临时禁用 DWM Mica/Acrylic 合成,避免拖拽卡顿
// ============================================================
void Win32Window::DetectBackdropCapabilities(bool* system_backdrop,
bool* mica_effect) {
*system_backdrop = false;
*mica_effect = false;
// 使用 RtlGetVersion 获取真实系统版本,避免 GetVersionExW 的兼容性谎言。
RTL_OSVERSIONINFOW osvi = {0};
osvi.dwOSVersionInfoSize = sizeof(osvi);
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
if (ntdll) {
auto rtl_get_version = reinterpret_cast<RtlGetVersionPtr>(
GetProcAddress(ntdll, "RtlGetVersion"));
if (rtl_get_version && STATUS_SUCCESS != rtl_get_version(&osvi)) {
return;
}
}
if (osvi.dwMajorVersion < 10) {
return;
}
HWND hwnd = GetDesktopWindow();
if (osvi.dwBuildNumber >= 22523) {
INT probe = DWMSBT_AUTO;
*system_backdrop = SUCCEEDED(DwmSetWindowAttribute(
hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &probe, sizeof(probe)));
}
if (osvi.dwBuildNumber >= 22000) {
BOOL probe = FALSE;
*mica_effect = SUCCEEDED(DwmSetWindowAttribute(
hwnd, DWMWA_MICA_EFFECT, &probe, sizeof(probe)));
}
}
int Win32Window::GetCurrentBackdropType(HWND hwnd) {
INT value = -1;
HRESULT hr = DwmGetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE,
&value, sizeof(value));
if (SUCCEEDED(hr)) {
return static_cast<int>(value);
}
return -1;
}
void Win32Window::SetBackdropType(HWND hwnd, int type) {
INT value = static_cast<INT>(type);
DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &value, sizeof(value));
}
void Win32Window::SetTransitionsEnabled(HWND hwnd, BOOL enabled) {
DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED,
&enabled, sizeof(enabled));
}
void Win32Window::EnterSizeMove(HWND hwnd) {
if (is_in_size_move_loop_) return;
is_in_size_move_loop_ = true;
DragDebugLog("EnterSizeMove begin");
// ============================================================
// 关键:调用 SetWindowCompositionAttribute(ACCENT_DISABLED)
//
// 这是 flutter_acrylic 的 Window.setEffect(disabled) 能立即禁用 Mica
// 的关键调用,之前的 v4~v7 修复都遗漏了这一步。
//
// SetWindowCompositionAttribute 是 user32.dll 的未公开 API
// 通过它设置窗口的 ACCENT_POLICY 为 ACCENT_DISABLED可以立即禁用
// 所有窗口合成效果(包括 Mica/Acrylic
//
// 仅靠 DwmSetWindowAttribute 是不够的——它是异步的DWM 不会立即应用
// 变更。SetWindowCompositionAttribute 能立即生效。
// ============================================================
{
HMODULE user32 = GetModuleHandleW(L"user32.dll");
if (user32) {
auto set_window_composition_attribute =
reinterpret_cast<WCA_SetWindowCompositionAttribute>(
GetProcAddress(user32, "SetWindowCompositionAttribute"));
if (set_window_composition_attribute) {
WCA_ACCENT_POLICY accent = {WCA_ACCENT_DISABLED, 2, 0, 0};
WCA_WINDOWCOMPOSITIONATTRIBDATA data;
data.Attrib = WCA_ATTRIB_ACCENT_POLICY;
data.pvData = &accent;
data.cbData = sizeof(accent);
set_window_composition_attribute(hwnd, &data);
DragDebugLog("SetWindowCompositionAttribute(ACCENT_DISABLED) called");
}
}
}
// 使用 {0, 0, 1, 0} 而非 {0, 0, 0, 0}
// 与 flutter_acrylic 的 setEffect(disabled) 一致。
// 注释At least one margin should be non-negative in order to show
// the DWM window shadow created by handling WM_NCCALCSIZE.
saved_margins_ = {-1, -1, -1, -1};
has_saved_margins_ = true;
MARGINS drag_margins = {0, 0, 1, 0};
DwmExtendFrameIntoClientArea(hwnd, &drag_margins);
// 禁用 System BackdropWin11 22523+
saved_backdrop_type_ = GetCurrentBackdropType(hwnd);
DragDebugLog(std::string("saved_backdrop_type=") +
std::to_string(saved_backdrop_type_));
if (saved_backdrop_type_ > DWMSBT_NONE || saved_backdrop_type_ == -1) {
SetBackdropType(hwnd, DWMSBT_NONE);
}
// 禁用 Mica EffectWin11 22000+
if (mica_effect_supported_) {
DWORD value = FALSE;
DwmGetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &saved_mica_enabled_,
sizeof(saved_mica_enabled_));
DragDebugLog(std::string("saved_mica_enabled=") +
std::to_string(saved_mica_enabled_));
if (saved_mica_enabled_) {
DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &value, sizeof(value));
}
}
// 禁用 DWM 过渡动画,进一步降低合成延迟
DwmGetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED,
&saved_transitions_enabled_,
sizeof(saved_transitions_enabled_));
SetTransitionsEnabled(hwnd, TRUE);
DragDebugLog("EnterSizeMove done");
}
void Win32Window::ExitSizeMove(HWND hwnd) {
if (!is_in_size_move_loop_) return;
is_in_size_move_loop_ = false;
DragDebugLog("ExitSizeMove begin");
// 恢复 backdrop
if (system_backdrop_supported_ && saved_backdrop_type_ > DWMSBT_NONE) {
SetBackdropType(hwnd, saved_backdrop_type_);
saved_backdrop_type_ = -1;
}
// 恢复 Mica effect
if (mica_effect_supported_ && saved_mica_enabled_) {
DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &saved_mica_enabled_,
sizeof(saved_mica_enabled_));
saved_mica_enabled_ = FALSE;
}
// 恢复 DWM 扩展帧边距
if (has_saved_margins_) {
DwmExtendFrameIntoClientArea(hwnd, &saved_margins_);
has_saved_margins_ = false;
}
// 恢复 DWM 过渡动画
SetTransitionsEnabled(hwnd, saved_transitions_enabled_);
DragDebugLog("ExitSizeMove done");
}

View File

@@ -2,6 +2,7 @@
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <dwmapi.h>
#include <functional>
#include <memory>
@@ -99,6 +100,22 @@ class Win32Window {
// Called when Destroy is called.
virtual void OnDestroy();
protected:
// ============================================================
// 拖拽时临时禁用 DWM Mica/Acrylic 合成,避免拖拽卡顿
// ============================================================
// 当前是否处于系统拖拽/调整大小模态循环中
// protectedFlutterWindow 需要访问此标志,判断是否需要在
// WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE 时禁用/恢复 Mica
bool is_in_size_move_loop_ = false;
// 本机是否支持 DWMWA_SYSTEMBACKDROP_TYPEWin11 22523+
bool system_backdrop_supported_ = false;
// 本机是否支持 DWMWA_MICA_EFFECTWin11 22000+
bool mica_effect_supported_ = false;
private:
friend class WindowClassRegistrar;
@@ -140,6 +157,46 @@ class Win32Window {
// 最小尺寸限制(物理像素)
static unsigned int min_width_;
static unsigned int min_height_;
// 自定义标题栏高度(逻辑像素),用于 WM_NCHITTEST 拖拽区域
// 与 Flutter 侧 DesktopTitleBarStyle.windows().height 保持一致
static double title_bar_height_;
// 自定义标题栏右侧按钮区域宽度(逻辑像素),用于 WM_NCHITTEST 排除可点击按钮
// 与 Flutter 侧 Windows 控制按钮总宽度46 * 3 = 138保持一致
static double title_bar_button_width_;
// 拖拽前保存的 backdrop 类型,拖拽结束恢复
// -1 表示尚未记录0=none, 2=tabbed, 3=mica, 4=transient(acrylic)
int saved_backdrop_type_ = -1;
// 拖拽前 Mica 启用状态,拖拽结束恢复
BOOL saved_mica_enabled_ = FALSE;
// 拖拽前 DWM 扩展帧边距,拖拽结束恢复
MARGINS saved_margins_ = {0, 0, 0, 0};
bool has_saved_margins_ = false;
// 拖拽前 DWM 过渡动画状态,拖拽结束恢复
BOOL saved_transitions_enabled_ = TRUE;
// 检测并缓存系统支持的 backdrop 能力
static void DetectBackdropCapabilities(bool* system_backdrop,
bool* mica_effect);
// 获取当前 DWM backdrop 类型(若支持),否则返回 -1
static int GetCurrentBackdropType(HWND hwnd);
// 设置 DWM backdrop 类型
static void SetBackdropType(HWND hwnd, int type);
// 设置 DWM 过渡动画启用/禁用
static void SetTransitionsEnabled(HWND hwnd, BOOL enabled);
protected:
// 进入/退出拖拽循环时调用(子类可覆写以在拖拽前后执行自定义逻辑)
virtual void EnterSizeMove(HWND hwnd);
virtual void ExitSizeMove(HWND hwnd);
};
#endif // RUNNER_WIN32_WINDOW_H_