Compare commits

...

2 Commits

Author SHA1 Message Date
Developer
81ea0f60dc fix: 修复 FactoryReset 误删用户文件并将应用数据迁移至 Application Support
- 修复 macOS/Windows/Linux 非沙盒下 FactoryReset 递归删除 ~/Documents 导致用户项目源代码丢失的严重 bug,改为仅删除已知应用专属文件/子目录并增加路径安全校验

- 数据库文件从 getApplicationDocumentsDirectory() 迁移到 getApplicationSupportDirectory()(应用专属),含自动迁移逻辑

- 启用 macOS Debug 模式沙盒,使开发环境与生产环境路径行为一致

- 统一迁移 13 处应用数据存储位置(Hive、聊天附件、字体、稍后读同步等)到 Application Support,应用启动时执行一次性迁移

- backup_service.dart 备份文件迁移至 Application Support,getBackupList() 兼容扫描新旧两个路径并去重

- clearCache() 同样修复危险递归清空逻辑

详见 CHANGELOG.md v6.136.0 ~ v6.138.0
2026-06-26 08:46:23 +08:00
Developer
0c9faf30b7 沙盒修复 2026-06-26 08:20:54 +08:00
20 changed files with 1037 additions and 138 deletions

View File

@@ -6,6 +6,257 @@
***
## [v6.138.0] - 2026-06-26
### 🔧 全量迁移应用数据至 Application Supportv6.137.0 后续)
#### 背景
v6.137.0 完成了数据库文件和 Debug 沙盒的迁移,但项目中仍有 13 处使用 `getApplicationDocumentsDirectory()` 存储应用内部数据Hive、聊天文件、字体等。本次统一迁移所有应用内部数据到 `getApplicationSupportDirectory()`,并修复 `clearCache()` 中与 FactoryReset 同样的危险清空逻辑。
#### 新增工具函数
**`lib/core/utils/platform/path_provider_native.dart`**
- 新增 `getAppDataDirectory()`:返回 `getApplicationSupportDirectory()`,推荐用于应用内部数据
- 新增 `migrateAppDataFromDocumentsToSupport()`:一次性迁移函数
- 迁移已知应用子目录:`image_cache``readlater_sync``chat_audio``chat_attachments``chat_trash``cloud_cache``file_transfer``fonts``backups`
- 迁移 Hive 文件:`*.hive``*.lock`
- 使用 copy 而非 rename复制成功后才删除旧文件
- 若新路径已存在同名文件/目录,跳过(避免覆盖)
- 进程内缓存迁移状态,避免重复执行
**`lib/core/utils/platform/platform_utils.dart`**
- 新增 `safeAppDataPath` getter暴露 `getAppDataDirectory()`
- 新增 `migrateAppData()` 函数:暴露迁移逻辑
**`lib/main.dart`**
-`KvStorage.init()` 之前调用 `pu.migrateAppData()`,确保存储初始化时使用新路径
#### 替换的使用点13 处)
| 文件 | 修改内容 |
|------|----------|
| `hive_safe_access.dart` | Hive.init 从 `Hive.initFlutter()` 改为 `Hive.init(applicationSupportPath)`,降级方案保留 Documents |
| `image_cache_metadata_service.dart` | 索引重建扫描新路径,兼容旧路径残留 |
| `readlater_device_sync_service.dart` | 同步文件保存到新路径,扫描兼容新旧路径 |
| `chat_audio_service.dart` | 录音文件存储到新路径 |
| `chat_file_service.dart` | 附件存储到新路径,路径解析兼容新旧 |
| `cloud_cache_service.dart` | 云端缓存存储到新路径 |
| `cache_manager_service.dart` | 文件传输缓存存储到新路径 |
| `font_download_service.dart` | 字体文件存储到新路径 |
| `font_management_notifier.dart` | 字体扫描新路径 |
#### 修复 clearCache() 危险清空逻辑
**`lib/features/settings/providers/general_settings_provider.dart`**
`clearCache()` 方法存在与 FactoryReset 同样的危险逻辑:遍历 `getApplicationDocumentsDirectory()` 返回目录的所有子项并递归删除。在 macOS/Windows/Linux 非沙盒下会删除用户 `~/Documents` 下所有内容。
修复方案:
1. **临时目录**:直接清空内容(应用专属,安全)
2. **Application Support**:带路径校验的清空(要求路径包含 `apps.xy.xianyan``xianyan`
3. **Documents**:仅清理已知应用子目录(`image_cache``chat_audio``chat_attachments` 等 9 个),不删除根内容
4. 新增 `_safeClearDirContents()` 辅助方法,支持路径校验
#### 保留的使用点(用户可见文件)
以下使用点保留 `getApplicationDocumentsDirectory()`,因为存储的是用户可见文件:
- `data_management_export_mixin.dart`:数据导出 `.xypk` 文件(导出后通过 share_plus 分享,路径不重要临时文件)
#### backup_service.dart 备份迁移(补充)
**`lib/core/services/data/backup_service.dart`**
- `backupDirPath``safeAppDirPath`Documents改为 `safeAppDataPath`Application Support
- `getBackupList()` 兼容新旧两个路径:同时扫描 Application Support 和 Documents合并结果并按文件名去重
- 新增 `_scanBackupDir()` 私有方法,提取目录扫描逻辑
**`lib/core/utils/platform/path_provider_native.dart`**
- 修正迁移子目录列表:`backups``xianyan_backups`(与 `backup_service.dart``_backupDirName` 保持一致)
**`lib/features/settings/presentation/more_settings_page.dart`** 和
**`lib/features/settings/providers/general_settings_provider.dart`**
- Documents 目录清理列表新增 `xianyan_backups` 子目录
- FactoryReset 的 `_safeClearAppFilesystemData()` 新增步骤 6.5:清理 Documents 下已知应用子目录
#### 验证
- `flutter analyze` 所有修改文件 → No issues found
- 迁移逻辑在应用启动时自动执行,用户无感知
- 兼容旧路径残留文件,迁移失败不影响使用
---
## [v6.137.0] - 2026-06-26
### 🔧 数据库路径迁移 + Debug 沙盒启用v6.136.0 后续优化)
#### 背景
v6.136.0 修复了 FactoryReset 误删项目源代码的严重 bug但暴露出两个深层问题
1. 数据库文件位置(`getApplicationDocumentsDirectory()`)在桌面非沙盒下不理想
2. Debug 模式未启用沙盒,导致开发环境与生产环境路径行为不一致(这是 bug 复现的根源)
本版本实施两个优化建议,从根因层面避免类似问题再次发生。
---
### 建议 1数据库文件迁移至应用专属目录
#### 修改文件
**`lib/core/storage/database/database_connection/native.dart`**
#### 路径变更
| 平台 | 旧路径 | 新路径 |
|------|--------|--------|
| macOS 非沙盒 | `~/Documents/xianyan.db` | `~/Library/Application Support/apps.xy.xianyan/xianyan.db` |
| macOS 沙盒 | `~/Library/Containers/.../Data/Documents/xianyan.db` | `~/Library/Containers/.../Data/Library/Application Support/xianyan.db` |
| iOS | `<app>/Documents/xianyan.db` | `<app>/Library/Application Support/xianyan.db` |
| Windows | `~/Documents/xianyan.db` | `%APPDATA%/<vendor>/<app>/xianyan.db` |
| Linux | `~/Documents/xianyan.db` | `~/.local/share/<app>/xianyan.db` |
| Android | `/data/data/<pkg>/files/xianyan.db` | `/data/data/<pkg>/files/xianyan.db`(路径相同,无影响) |
#### 自动迁移逻辑
新增 `_resolveDatabaseFile()` 方法,在 `LazyDatabase` 初始化阶段执行:
1. **新路径已存在** → 直接使用(迁移已完成或全新安装)
2. **旧路径存在数据库文件** → 复制到新路径(含 `-wal`/`-shm`/`-journal` 辅助文件),删除旧文件
3. **都不存在** → 使用新路径(首次安装)
4. **迁移失败** → 回退到旧路径,保证数据可访问
#### 安全性保障
- 使用 `copy` 而非 `rename`,复制成功后才删除旧文件,避免迁移中断导致数据丢失
- 迁移在 `LazyDatabase` 初始化阶段执行,此时数据库未打开,无并发风险
- 详细的日志记录迁移过程,便于排查问题
#### 联动修改
**`lib/features/settings/presentation/more_settings_page.dart`**
- `_safeClearAppFilesystemData()` 方法第 6.1 步扩展:同时清理新旧两个路径下的数据库文件
- 新路径:`getApplicationSupportDirectory()/xianyan.db`
- 旧路径:`getApplicationDocumentsDirectory()/xianyan.db`(兼容历史版本残留)
---
### 建议 2启用 Debug 沙盒
#### 修改文件
**`macos/Runner/DebugProfile.entitlements`**
- `com.apple.security.app-sandbox``false``true`
#### 决策过程
启用 Debug 沙盒与建议 1 存在冲突:
- 沙盒内应用**无法访问沙盒外路径**
- 若先迁移数据库到沙盒外的 `~/Library/Application Support/apps.xy.xianyan/`,再启用沙盒,迁移逻辑会尝试从沙盒外旧路径复制,但沙盒内应用无权访问 → 迁移失败
经用户确认,选择"启用 Debug 沙盒 + 接受数据丢失"方案:
- ✅ 项目源代码不会丢失IDE 进程不受沙盒影响)
- ⚠️ Debug 模式下应用内数据(数据库、收藏、历史等)会丢失,开发者需重新登录/创建
- ✅ 长期收益:开发环境与生产环境路径行为一致,避免类似 bug 复现
#### 沙盒启用后路径变化Debug 模式)
| API | 启用前(非沙盒) | 启用后(沙盒) |
|-----|------------------|----------------|
| `getApplicationDocumentsDirectory()` | `~/Documents` | `~/Library/Containers/apps.xy.xianyan/Data/Documents` |
| `getApplicationSupportDirectory()` | `~/Library/Application Support/apps.xy.xianyan` | `~/Library/Containers/apps.xy.xianyan/Data/Library/Application Support` |
| `getTemporaryDirectory()` | `~/Library/Caches/apps.xy.xianyan` | `~/Library/Containers/apps.xy.xianyan/Data/Library/Caches` |
#### 影响范围评估
- ✅ Hive 存储:迁移到沙盒内 Documents安全
- ✅ 图片缓存元数据:迁移到沙盒内 Documents安全
- ✅ 聊天音频/文件:迁移到沙盒内 Documents安全
- ✅ 字体下载服务:迁移到沙盒内 Documents安全
- ✅ LocalSend 文件接收:存储在应用专属目录,不依赖 `~/Downloads`,安全
- ✅ 稍后读同步:迁移到沙盒内 Documents安全
- ✅ Flutter 调试Dart VM Service 通过 localhost 通信,沙盒允许 localhost 网络访问
#### 用户须知
- Debug 模式下首次启动会显示空数据库,需重新登录账户
- 旧 Debug 数据库文件残留在沙盒外路径,可手动清理:
- `~/Documents/xianyan.db`
- `~/Library/Application Support/apps.xy.xianyan/xianyan.db`
---
### 举一反三
1. **其他使用 `getApplicationDocumentsDirectory()` 的位置**:项目中仍有 13 处使用此 API 存储应用数据Hive、图片缓存、聊天文件、字体、稍后读等。本次未一并迁移因为
- 启用沙盒后,这些路径自动变为应用专属(沙盒内 Documents不再有污染问题
- 桌面非沙盒场景下仍有潜在风险,但优先级低于数据库
- 后续可考虑统一迁移到 `getApplicationSupportDirectory()`
2. **数据库迁移日志监控**:建议在 Release 模式下监控 `Database migration:` 日志,了解用户迁移情况,及时发现问题
3. **沙盒启用后的回归测试**:建议在 Debug 模式下测试以下功能:
- 数据库创建/读写
- 文件下载(字体、图片)
- LocalSend 文件接收
- 相机/麦克风权限
- 定位功能
---
## [v6.136.0] - 2026-06-26
### 🚨 严重 Bug 修复(数据丢失 - macOS/Windows/Linux 非沙盒模式)
#### 背景
用户反馈macOS Debug 模式下,进入「更多设置 → 重置与清理 → 清空软件所有数据」输入"重置"确认后,**整个项目源代码文件全部丢失**。
#### 根因
`lib/features/settings/presentation/more_settings_page.dart``_executeFactoryReset` 方法第 6 步缓存清理逻辑存在严重缺陷:
```dart
// ❌ 原 dangerous 代码
final dirs = <Directory>[];
dirs.add(await getTemporaryDirectory());
dirs.add(await getApplicationDocumentsDirectory()); // macOS 非沙盒 → ~/Documents
dirs.add(await getApplicationSupportDirectory());
for (final dir in dirs) {
await for (final entity in dir.list()) {
if (entity is Directory) {
await entity.delete(recursive: true); // 递归删除子目录!
}
}
}
```
- macOS Debug 模式下 [DebugProfile.entitlements](file:///Users/wushu/Documents/trae_projects/project/xianyan/macos/Runner/DebugProfile.entitlements) 中 `com.apple.security.app-sandbox = false`(未启用沙盒)
- 非沙盒模式下 `getApplicationDocumentsDirectory()` 返回**用户级 `~/Documents`**(不是应用容器目录)
- 数据库连接 [native.dart#L27-L28](file:///Users/wushu/Documents/trae_projects/project/xianyan/lib/core/storage/database/database_connection/native.dart#L27-L28) 将 `xianyan.db` 放在 `~/Documents/xianyan.db`
- 代码遍历 `~/Documents` 下所有子项并递归删除 → `~/Documents/trae_projects` 被递归删除 → **整个项目源代码丢失**
- 同样的危险在 Windows/Linux 非沙盒下都存在
#### 修复方案
**`lib/features/settings/presentation/more_settings_page.dart`**
1. **新增 `_safeClearAppFilesystemData()` 方法**,遵循三大安全原则:
- 永不递归删除 `getApplicationDocumentsDirectory()` 等返回目录的根内容
- 仅删除已知应用专属路径(数据库文件、应用标识子目录)
- 路径校验:清理子目录前验证路径包含 `apps.xy.xianyan``xianyan`,否则跳过
2. **精确删除数据库文件**:仅删除 `xianyan.db``xianyan.db-wal``xianyan.db-shm``xianyan.db-journal`,不遍历父目录
3. **`_safeClearDirectoryContents()` 通用方法**
- `requireAppIdentifier` 参数:要求路径包含应用标识才清理
- 仅清空目录的直接子项,不删除目录本身
- 单个文件删除失败不影响整体清理
- 详细的日志记录
4. **分层清理策略**
- 数据库文件:精确文件删除(无条件)
- 临时目录:直接清空(应用专属,安全)
- 应用支持目录:带路径校验的清空
- 应用缓存目录:直接清空(应用专属,安全)
- Flutter ImageCache内存缓存清理
#### 影响范围
- ✅ macOS 非沙盒Debug 模式):修复项目源代码丢失问题
- ✅ Windows 非沙盒:修复 `Documents` 目录被清空问题
- ✅ Linux 非沙盒:修复用户主目录被清空问题
- ✅ iOS/Android 沙盒:原行为正常,现增加路径校验作为防御
- ✅ macOS Release 沙盒模式:原行为正常,现增加路径校验作为防御
#### 举一反三
- 数据库连接文件位置(`native.dart`)使用 `getApplicationDocumentsDirectory()` 在桌面非沙盒下不理想,建议未来迁移至 `getApplicationSupportDirectory()`(应用专属,更安全)
- 「数据管理页面」的 `_clearAllData` 方法仅清理数据库表,不涉及文件系统删除,安全
- 「缓存管理页面」的清理操作通过 `CacheService` 调用,使用应用专属路径,安全
---
## [v6.135.0] - 2026-06-26
### 🧹 仓库瘦身(历史大文件清理)

View File

@@ -1,9 +1,11 @@
/// ============================================================
/// 闲言APP — 自动备份服务
/// 创建时间: 2026-05-04
/// 更新时间: 2026-06-05
/// 更新时间: 2026-06-26
/// 作用: 定时自动备份用户数据到本地,支持备份管理和恢复
/// 上次更新: 完善Web端守卫所有公共方法添加isWeb早期返回
/// 上次更新: 备份存储路径从 getApplicationDocumentsDirectory() 迁移到
/// getApplicationSupportDirectory()(应用专属目录),避免桌面非沙盒
/// 下污染用户 ~/Documents 目录。getBackupList() 兼容旧路径残留文件。
/// ============================================================
import 'dart:convert';
@@ -75,12 +77,18 @@ class BackupService {
return DateTime.now().difference(last) >= interval;
}
/// 备份目录路径应用专属Application Support
///
/// 历史路径为 getApplicationDocumentsDirectory()/xianyan_backups
/// 在 macOS/Windows/Linux 非沙盒下会污染用户 ~/Documents 目录。
/// 现迁移到 getApplicationSupportDirectory()/xianyan_backups应用专属
/// 应用启动时 migrateAppData() 会自动迁移旧路径下的备份文件。
static Future<String> get backupDirPath async {
if (pu.isWeb) throw UnsupportedError('Web端不支持文件系统操作');
var dirPath = await pu.safeAppDirPath;
// 无法获取应用文档目录时降级使用临时目录如iOS模拟器某些环境
var dirPath = await pu.safeAppDataPath;
// 无法获取应用支持目录时降级使用临时目录如iOS模拟器某些环境
if (dirPath == null) {
Log.w('BackupService: 无法获取应用文档目录,降级使用临时目录');
Log.w('BackupService: 无法获取应用支持目录,降级使用临时目录');
dirPath = await pu.safeTempDirPath;
if (dirPath == null) {
throw UnsupportedError('无法获取任何可用目录');
@@ -93,38 +101,83 @@ class BackupService {
return backupDir.path;
}
/// 获取备份列表(兼容新旧两个路径)
///
/// 同时扫描 Application Support新路径主要和 Documents旧路径兼容
/// 合并结果并按创建时间降序排序。旧路径扫描用于兼容迁移失败时的残留文件。
static Future<List<BackupFileInfo>> getBackupList() async {
if (pu.isWeb) return [];
final dirPath = await backupDirPath;
final dir = Directory(dirPath);
final files = await dir
.list()
.where(
(f) =>
f is File &&
f.path.endsWith(_backupExtension) &&
File(f.path).path.split('/').last.startsWith(_backupFilePrefix),
)
.cast<File>()
.toList();
final backups = <BackupFileInfo>[];
for (final file in files) {
final stat = await file.stat();
final fileName = file.path.split(Platform.pathSeparator).last;
final sizeBytes = await file.length();
backups.add(
BackupFileInfo(
path: file.path,
fileName: fileName,
createdAt: stat.modified,
sizeBytes: sizeBytes,
),
);
// 1. 扫描新路径Application Support主要
try {
final newDirPath = await backupDirPath;
backups.addAll(await _scanBackupDir(newDirPath));
} catch (e) {
Log.w('BackupService: 扫描新路径备份失败', e);
}
backups.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return backups;
// 2. 扫描旧路径Documents兼容迁移失败时的残留文件
try {
final oldDirPath = await pu.safeAppDirPath;
if (oldDirPath != null) {
final legacyDir = Directory('$oldDirPath/$_backupDirName');
if (await legacyDir.exists()) {
backups.addAll(await _scanBackupDir(legacyDir.path));
}
}
} catch (_) {}
// 去重(同一文件名只保留一个,优先新路径)
final seen = <String>{};
final deduped = <BackupFileInfo>[];
for (final backup in backups) {
if (seen.add(backup.fileName)) {
deduped.add(backup);
}
}
deduped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return deduped;
}
/// 扫描指定目录下的备份文件
static Future<List<BackupFileInfo>> _scanBackupDir(String dirPath) async {
try {
final dir = Directory(dirPath);
if (!await dir.exists()) return [];
final files = await dir
.list()
.where(
(f) =>
f is File &&
f.path.endsWith(_backupExtension) &&
File(f.path).path.split('/').last.startsWith(_backupFilePrefix),
)
.cast<File>()
.toList();
final backups = <BackupFileInfo>[];
for (final file in files) {
final stat = await file.stat();
final fileName = file.path.split(Platform.pathSeparator).last;
final sizeBytes = await file.length();
backups.add(
BackupFileInfo(
path: file.path,
fileName: fileName,
createdAt: stat.modified,
sizeBytes: sizeBytes,
),
);
}
return backups;
} catch (e) {
Log.w('BackupService: 扫描备份目录失败: $dirPath', e);
return [];
}
}
static Future<String> performBackup() async {

View File

@@ -470,15 +470,31 @@ class ImageCacheMetadataService {
}
}
final docDir = await getApplicationDocumentsDirectory();
final docImageDir = Directory('${docDir.path}/image_cache');
if (await docImageDir.exists()) {
await for (final entity in docImageDir.list(recursive: true)) {
if (entity is File) {
await indexFile(entity.path);
// 应用数据目录新路径Application Support主要
try {
final supportDir = await getApplicationSupportDirectory();
final supportImageDir = Directory('${supportDir.path}/image_cache');
if (await supportImageDir.exists()) {
await for (final entity in supportImageDir.list(recursive: true)) {
if (entity is File) {
await indexFile(entity.path);
}
}
}
}
} catch (_) {}
// 旧路径兼容Documents/image_cache迁移失败时仍有残留文件
try {
final docDir = await getApplicationDocumentsDirectory();
final docImageDir = Directory('${docDir.path}/image_cache');
if (await docImageDir.exists()) {
await for (final entity in docImageDir.list(recursive: true)) {
if (entity is File) {
await indexFile(entity.path);
}
}
}
} catch (_) {}
Log.i('ImageCacheMetadataService: 索引重建完成');
}

View File

@@ -122,20 +122,40 @@ class ReadlaterDeviceSyncService {
}
/// 扫描传输目录中未处理的稍后读同步文件
///
/// 同时扫描新旧两个路径Application Support + Documents
/// 兼容迁移前残留的同步文件。
Future<int> scanAndImportPendingSyncFiles() async {
try {
if (kIsWeb) return 0;
final appDocDir = await getApplicationDocumentsDirectory();
final syncDir = Directory('${appDocDir.path}/$_syncDirName');
if (!await syncDir.exists()) return 0;
int totalImported = 0;
await for (final entity in syncDir.list()) {
if (entity is File && entity.path.contains(_syncFilePrefix)) {
final imported = await _importFromFile(entity);
totalImported += imported;
// 新路径Application Support主要
try {
final supportDir = await getApplicationSupportDirectory();
final syncDir = Directory('${supportDir.path}/$_syncDirName');
if (await syncDir.exists()) {
await for (final entity in syncDir.list()) {
if (entity is File && entity.path.contains(_syncFilePrefix)) {
totalImported += await _importFromFile(entity);
}
}
}
}
} catch (_) {}
// 旧路径兼容Documents迁移失败时仍有残留文件
try {
final docDir = await getApplicationDocumentsDirectory();
final syncDir = Directory('${docDir.path}/$_syncDirName');
if (await syncDir.exists()) {
await for (final entity in syncDir.list()) {
if (entity is File && entity.path.contains(_syncFilePrefix)) {
totalImported += await _importFromFile(entity);
}
}
}
} catch (_) {}
if (totalImported > 0) {
Log.i('ReadlaterSync: 扫描导入 $totalImported 条稍后读消息');
@@ -184,7 +204,7 @@ class ReadlaterDeviceSyncService {
/// 保存同步数据为临时文件
Future<String?> _saveSyncFile(Map<String, dynamic> syncData) async {
try {
final appDocDir = await getApplicationDocumentsDirectory();
final appDocDir = await getApplicationSupportDirectory();
final syncDir = Directory('${appDocDir.path}/$_syncDirName');
if (!await syncDir.exists()) {
await syncDir.create(recursive: true);

View File

@@ -1,9 +1,15 @@
/// ============================================================
/// 闲言APP — Drift原生数据库连接 (Android/iOS/macOS/Windows/Linux/OpenHarmony)
/// 创建时间: 2026-04-25
/// 更新时间: 2026-06-15
/// 更新时间: 2026-06-26
/// 作用: 原生平台数据库连接OpenHarmony 使用 sqflite_ohos 桥接
/// 上次更新: 移除sqlite3_flutter_libs依赖迁移至sqlite3包
/// 上次更新: 1. 数据库文件位置从 getApplicationDocumentsDirectory() 迁移到
/// getApplicationSupportDirectory()(应用专属目录)。
/// 原位置在 macOS/Windows/Linux 非沙盒模式下为用户公共 ~/Documents
/// 污染用户文档目录且存在被误删风险。新位置为应用专属目录,更安全。
/// 2. 已实现自动迁移:检测旧路径存在数据库文件时复制到新路径并删除旧文件,
/// 迁移失败时回退到旧路径保证数据可访问。
/// 3. 移除sqlite3_flutter_libs依赖迁移至sqlite3包
/// ============================================================
import 'dart:io';
@@ -17,6 +23,9 @@ import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
import 'ohos.dart';
/// 数据库文件名
const _kDbName = 'xianyan.db';
QueryExecutor openConnection() {
if (pu.isOhos) {
Log.i('Drift: 检测到 OpenHarmony 平台,使用 sqflite_ohos 后端');
@@ -24,9 +33,88 @@ QueryExecutor openConnection() {
}
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'xianyan.db'));
return NativeDatabase.createInBackground(file);
final dbFile = await _resolveDatabaseFile();
Log.i('Drift: 数据库路径 = ${dbFile.path}');
return NativeDatabase.createInBackground(dbFile);
});
}
/// 解析数据库文件位置,必要时执行旧路径 → 新路径迁移
///
/// **历史路径**`getApplicationDocumentsDirectory()/xianyan.db`
/// - macOS 非沙盒:`~/Documents/xianyan.db`(污染用户 Documents 目录)
/// - 桌面非沙盒:用户公共 Documents 目录(不安全,可能被误删)
///
/// **新路径**`getApplicationSupportDirectory()/xianyan.db`
/// - macOS 非沙盒:`~/Library/Application Support/apps.xy.xianyan/xianyan.db`(应用专属)
/// - macOS 沙盒:`~/Library/Containers/apps.xy.xianyan/Data/Library/Application Support/xianyan.db`
/// - iOS`<app>/Library/Application Support/xianyan.db`(应用专属)
/// - Windows`%APPDATA%/<vendor>/<app>/xianyan.db`(应用专属)
/// - Linux`~/.local/share/<app>/xianyan.db`(应用专属)
/// - Android`/data/data/<pkg>/files/xianyan.db`(与 Documents 相同路径)
///
/// **迁移策略**
/// 1. 新路径已存在 → 直接使用(迁移已完成或全新安装)
/// 2. 旧路径存在数据库文件 → 复制到新路径(含 -wal/-shm/-journal删除旧文件
/// 3. 都不存在 → 使用新路径(首次安装)
///
/// **安全性**
/// - 使用 copy 而非 rename复制成功后才删除旧文件避免迁移中断导致数据丢失
/// - 迁移失败时回退到旧路径,保证数据可访问
/// - 迁移在 LazyDatabase 初始化阶段执行,此时数据库未打开,无并发风险
Future<File> _resolveDatabaseFile() async {
// 新路径:应用专属支持目录
final newFolder = await getApplicationSupportDirectory();
final newFile = File(p.join(newFolder.path, _kDbName));
// 1. 新路径已存在,直接使用(迁移已完成或全新安装)
if (await newFile.exists()) {
return newFile;
}
// 2. 检查旧路径是否存在数据库文件
final oldFolder = await getApplicationDocumentsDirectory();
final oldFile = File(p.join(oldFolder.path, _kDbName));
// Android 上两个路径相同getApplicationSupportDirectory 在 Android 上
// 等同于 getApplicationDocumentsDirectory已被上面 newFile.exists() 拦截,
// 此处再防御性判断一次避免极端情况下重复处理
if (oldFile.path == newFile.path) {
return newFile;
}
if (await oldFile.exists()) {
Log.i('Database migration: 检测到旧路径数据库 ${oldFile.path},开始迁移');
try {
// 确保新目录存在
await newFolder.create(recursive: true);
// 复制主数据库文件
await oldFile.copy(newFile.path);
// 同时迁移 WAL/SHM/journal 辅助文件(若存在)
for (final suffix in ['-wal', '-shm', '-journal']) {
final oldAux = File(p.join(oldFolder.path, '$_kDbName$suffix'));
if (await oldAux.exists()) {
await oldAux.copy(p.join(newFolder.path, '$_kDbName$suffix'));
}
}
Log.i('Database migration: 数据库已从 ${oldFile.path} 迁移至 ${newFile.path}');
// 迁移成功后删除旧文件(删除失败不影响使用,仅记录警告)
try {
await oldFile.delete();
for (final suffix in ['-wal', '-shm', '-journal']) {
final oldAux = File(p.join(oldFolder.path, '$_kDbName$suffix'));
if (await oldAux.exists()) await oldAux.delete();
}
Log.i('Database migration: 旧路径文件已清理');
} catch (e) {
Log.w('Database migration: 旧文件删除失败(不影响使用)', e);
}
} catch (e) {
Log.e('Database migration: 迁移失败,继续使用旧路径', e);
return oldFile;
}
}
return newFile;
}

View File

@@ -51,37 +51,42 @@ class HiveSafeAccess {
if (_instance._hiveInitialized) return;
if (_instance._hiveInitFailed) return; // 已尝试过且失败,不再重试
// 方案1: 标准Hive.initFlutter()
// 方案1: 使用 Hive.init() + getApplicationSupportDirectory()
// 应用专属目录,避免污染用户 ~/Documents桌面非沙盒下尤为重要
// 注:原 Hive.initFlutter() 内部使用 getApplicationDocumentsDirectory()
// 在 macOS/Windows/Linux 非沙盒下会污染用户文档目录
try {
Log.i('[HiveSafe] 执行 Hive.initFlutter()...');
await Hive.initFlutter();
Log.i('[HiveSafe] 执行 Hive.init(applicationSupportPath)...');
if (kIsWeb) {
// Web 端 Hive.initFlutter 内部处理了路径,直接调用
await Hive.initFlutter();
} else {
final dir = await getApplicationSupportDirectory();
Hive.init(dir.path);
}
_instance._hiveInitialized = true;
Log.i('[HiveSafe] Hive 初始化完成');
return;
} catch (e) {
Log.w('[HiveSafe] Hive.initFlutter()失败,尝试降级方案', e);
Log.w('[HiveSafe] Hive.init(applicationSupportPath) 失败,尝试降级方案', e);
}
// 方案2: 降级使用Hive.init() + 手动获取路径绕过objective_c依赖
// Hive.initFlutter()内部调用getApplicationDocumentsDirectory()
// 在iOS模拟器上因objective_c库问题会失败这里手动获取路径并降级
// Web端: getApplicationDocumentsDirectory()不可用需kIsWeb保护
// 方案2: 降级使用 Hive.init() + getApplicationDocumentsDirectory()
// 在 iOS 模拟器上因 objective_c 库问题 getApplicationSupportDirectory() 可能失败
// 此时降级到 Documents 目录(沙盒内仍为应用专属,安全)
try {
Log.i('[HiveSafe] 降级方案: 使用Hive.init()手动指定路径...');
Log.i('[HiveSafe] 降级方案: 使用 Hive.init() + Documents...');
String hivePath;
try {
if (kIsWeb) {
// Web端不需要手动指定路径Hive.initFlutter()应该已经处理了
// 如果走到这里说明initFlutter也失败了Web端无法继续
throw UnsupportedError('Hive init failed on web');
}
final dir = await getApplicationDocumentsDirectory();
hivePath = dir.path;
} catch (_) {
if (kIsWeb) {
hivePath = '/tmp'; // Web端不会执行到这里但作为安全措施
hivePath = '/tmp';
} else {
// path_provider也不可用时使用系统临时目录
hivePath = Directory.systemTemp.path;
}
}

View File

@@ -1,13 +1,26 @@
/// ============================================================
/// 闲言APP — path_provider 原生实现
/// 创建时间: 2026-05-20
/// 更新时间: 2026-05-20
/// 作用: 原生平台使用path_provider获取目录路径
/// 上次更新: 初始创建
/// 更新时间: 2026-06-26
/// 作用: 原生平台使用 path_provider 获取目录路径;提供应用数据目录统一入口
/// 和一次性迁移(从 getApplicationDocumentsDirectory() 迁移到
/// getApplicationSupportDirectory()
/// 上次更新: 新增 getAppDataDirectory() 和 migrateAppDataFromDocumentsToSupport()
/// 统一应用数据存储位置,避免桌面非沙盒下污染用户 ~/Documents 目录。
/// 保留 getAppDirPathImpl() 用于用户可见文件(导出/分享)。
/// ============================================================
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:xianyan/core/utils/logger.dart';
/// 旧的应用数据存储位置(仅用于用户可见文件:导出、分享等)
///
/// ⚠️ 不推荐用于应用内部数据存储。应用内部数据请使用 [getAppDataDirectory]。
/// 原因:在 macOS/Windows/Linux 非沙盒模式下,此路径为用户公共 ~/Documents
/// 污染用户文档目录且存在被误删风险。
Future<String?> getAppDirPathImpl() async {
try {
return (await getApplicationDocumentsDirectory()).path;
@@ -16,6 +29,23 @@ Future<String?> getAppDirPathImpl() async {
}
}
/// 新的应用数据存储位置(应用专属,推荐用于应用内部数据)
///
/// 各平台路径:
/// - macOS 非沙盒:`~/Library/Application Support/apps.xy.xianyan`
/// - macOS 沙盒:`~/Library/Containers/apps.xy.xianyan/Data/Library/Application Support`
/// - iOS`<app>/Library/Application Support`
/// - Windows`%APPDATA%/<vendor>/<app>`
/// - Linux`~/.local/share/<app>`
/// - Android`/data/data/<pkg>/files`(与 Documents 相同)
Future<String?> getAppDataDirectory() async {
try {
return (await getApplicationSupportDirectory()).path;
} catch (_) {
return null;
}
}
Future<String?> getTempDirPathImpl() async {
try {
return (await getTemporaryDirectory()).path;
@@ -23,3 +53,150 @@ Future<String?> getTempDirPathImpl() async {
return null;
}
}
// ============================================================
// 一次性迁移Documents → Application Support
// ============================================================
/// 已知的应用数据子目录名(用于一次性迁移)
const _kAppDataSubdirs = <String>[
'image_cache', // 图片缓存
'readlater_sync', // 稍后读同步
'chat_audio', // 聊天录音
'chat_attachments', // 聊天附件
'chat_trash', // 聊天回收站
'cloud_cache', // 云端缓存
'file_transfer', // 文件传输
'fonts', // 字体文件
'xianyan_backups', // 自动备份(与 backup_service.dart 的 _backupDirName 保持一致)
];
/// Hive 文件扩展名(用于迁移 Hive box 文件)
const _kHiveFileExtensions = ['.hive', '.lock'];
/// 标记迁移是否已完成(避免重复迁移,进程内缓存)
bool _migrationCompleted = false;
/// 将应用数据从 getApplicationDocumentsDirectory() 迁移到
/// getApplicationSupportDirectory()
///
/// **迁移策略**
/// 1. 若两个路径相同Android跳过迁移
/// 2. 复制旧路径下的应用数据子目录到新路径
/// 3. 复制旧路径下的 Hive 文件(*.hive, *.lock到新路径
/// 4. 复制成功后删除旧路径下的对应文件/目录
///
/// **安全性**
/// - 使用 copy 而非 rename复制成功后才删除旧文件
/// - 若新路径已存在同名文件/目录,跳过(避免覆盖)
/// - 单个文件/目录迁移失败不影响其他迁移
/// - 详细的日志记录
///
/// 此函数应在应用启动时、Hive 初始化之前调用一次。
Future<void> migrateAppDataFromDocumentsToSupport() async {
if (_migrationCompleted) return;
try {
final oldDir = await getApplicationDocumentsDirectory();
final newDir = await getApplicationSupportDirectory();
// Android 上两个路径相同,无需迁移
if (oldDir.path == newDir.path) {
_migrationCompleted = true;
return;
}
if (!await oldDir.exists()) {
_migrationCompleted = true;
return;
}
// 确保新目录存在
await newDir.create(recursive: true);
var migratedCount = 0;
// 1. 迁移已知应用数据子目录
for (final subdirName in _kAppDataSubdirs) {
try {
final oldSubdir = Directory(p.join(oldDir.path, subdirName));
final newSubdir = Directory(p.join(newDir.path, subdirName));
if (!await oldSubdir.exists()) continue;
// 若新路径已存在同名目录,跳过(避免覆盖)
if (await newSubdir.exists()) continue;
// 复制整个子目录
await _copyDirectory(oldSubdir, newSubdir);
Log.i('AppDataMigration: 已迁移目录 $subdirName');
migratedCount++;
// 复制成功后删除旧目录
try {
await oldSubdir.delete(recursive: true);
} catch (e) {
Log.w('AppDataMigration: 删除旧目录 $subdirName 失败', e);
}
} catch (e) {
Log.w('AppDataMigration: 迁移目录 $subdirName 失败', e);
}
}
// 2. 迁移 Hive 文件(*.hive, *.lock
try {
await for (final entity in oldDir.list()) {
if (entity is! File) continue;
final fileName = p.basename(entity.path);
final isHiveFile = _kHiveFileExtensions.any(
(ext) => fileName.endsWith(ext),
);
if (!isHiveFile) continue;
try {
final newFile = File(p.join(newDir.path, fileName));
if (await newFile.exists()) continue; // 已存在,跳过
await entity.copy(newFile.path);
Log.i('AppDataMigration: 已迁移 Hive 文件 $fileName');
migratedCount++;
// 复制成功后删除旧文件
try {
await entity.delete();
} catch (e) {
Log.w('AppDataMigration: 删除旧 Hive 文件 $fileName 失败', e);
}
} catch (e) {
Log.w('AppDataMigration: 迁移 Hive 文件 $fileName 失败', e);
}
}
} catch (e) {
Log.w('AppDataMigration: 扫描 Hive 文件失败', e);
}
if (migratedCount > 0) {
Log.i('AppDataMigration: 迁移完成,共迁移 $migratedCount 个项目');
} else {
Log.i('AppDataMigration: 无需迁移(未发现旧路径应用数据)');
}
_migrationCompleted = true;
} catch (e) {
Log.e('AppDataMigration: 迁移失败', e);
// 不标记完成,下次启动可重试
}
}
/// 递归复制目录
Future<void> _copyDirectory(Directory source, Directory destination) async {
await destination.create(recursive: true);
await for (final entity in source.list()) {
if (entity is File) {
await entity.copy(p.join(destination.path, p.basename(entity.path)));
} else if (entity is Directory) {
await _copyDirectory(
entity,
Directory(p.join(destination.path, p.basename(entity.path))),
);
}
}
}

View File

@@ -1,11 +1,17 @@
/// ============================================================
/// 闲言APP — path_provider Web Stub
/// 创建时间: 2026-05-20
/// 更新时间: 2026-05-20
/// 更新时间: 2026-06-26
/// 作用: Web端path_provider不可用提供空实现返回null
/// 上次更新: 初始创建
/// 上次更新: 同步新增 getAppDataDirectory() 和 migrateAppDataFromDocumentsToSupport()
/// 的 Web 端空实现
/// ============================================================
Future<String?> getAppDirPathImpl() async => null;
Future<String?> getAppDataDirectory() async => null;
Future<String?> getTempDirPathImpl() async => null;
/// Web 端无文件系统,迁移为空操作
Future<void> migrateAppDataFromDocumentsToSupport() async {}

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 平台工具类
/// 创建时间: 2026-04-25
/// 更新时间: 2026-06-25
/// 更新时间: 2026-06-26
/// 作用: 封装平台相关操作隔离dart:io/dart:html支持鸿蒙
/// 上次更新: 新增isIntelMac检测Intel CPU MacBook
/// 上次更新: 新增 safeAppDataPath 和 migrateAppData统一应用数据存储位置
/// ============================================================
import 'package:flutter/widgets.dart';
@@ -30,11 +30,25 @@ bool get supportsWebView3D => supportsWebView3DImpl;
bool get isIntelMac => isIntelMacImpl;
/// 安全获取应用文档目录路径(Web端返回null)
///
/// ⚠️ 仅用于用户可见文件(导出、分享等)。应用内部数据请使用 [safeAppDataPath]。
Future<String?> get safeAppDirPath => getAppDirPathImpl();
/// 安全获取应用数据目录路径(Web端返回null)
///
/// 推荐用于应用内部数据存储数据库、缓存、Hive、附件等
/// 返回应用专属目录,避免污染用户 ~/Documents。
Future<String?> get safeAppDataPath => getAppDataDirectory();
/// 安全获取临时目录路径(Web端返回null)
Future<String?> get safeTempDirPath => getTempDirPathImpl();
/// 将应用数据从 Documents 目录迁移到 Application Support 目录
///
/// 应在应用启动时、Hive 初始化之前调用一次。
/// 详见 path_provider_native.dart 中的实现说明。
Future<void> migrateAppData() => migrateAppDataFromDocumentsToSupport();
/// 鸿蒙端设备特性检测
class OhosDeviceCapabilities {
OhosDeviceCapabilities._();

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 工具库统一导出
/// 创建时间: 2026-05-23
/// 更新时间: 2026-05-31
/// 更新时间: 2026-06-26
/// 作用: barrel export统一导出所有 utils 公共 API
/// 上次更新: 新增rtl_utils导出
/// 上次更新: 同步 path_provider 新增函数的 hide 列表
/// ============================================================
// ── Root ──────────────────────────────────────────────────────
@@ -18,8 +18,8 @@ export 'platform/clipboard_bridge.dart';
export 'platform/isolate_stub.dart' hide Isolate, RawReceivePort, SendPort;
export 'platform/platform_io_stub.dart' hide isWebImpl, isOhosImpl, isAndroidImpl, isIOSImpl, isMacOSImpl, isWindowsImpl, isLinuxImpl, isMobileImpl, isDesktopImpl, platformNameImpl, platformVersionImpl, supportsFilesystemImpl, supportsGPU3DImpl, supportsWebView3DImpl, isIntelMacImpl;
export 'platform/platform_io_native.dart' hide isWebImpl, isOhosImpl, isAndroidImpl, isIOSImpl, isMacOSImpl, isWindowsImpl, isLinuxImpl, isMobileImpl, isDesktopImpl, platformNameImpl, platformVersionImpl, supportsFilesystemImpl, supportsGPU3DImpl, supportsWebView3DImpl, isIntelMacImpl;
export 'platform/path_provider_stub.dart' hide getAppDirPathImpl, getTempDirPathImpl;
export 'platform/path_provider_native.dart' hide getAppDirPathImpl, getTempDirPathImpl;
export 'platform/path_provider_stub.dart' hide getAppDirPathImpl, getAppDataDirectory, getTempDirPathImpl, migrateAppDataFromDocumentsToSupport;
export 'platform/path_provider_native.dart' hide getAppDirPathImpl, getAppDataDirectory, getTempDirPathImpl, migrateAppDataFromDocumentsToSupport;
// ── UI ────────────────────────────────────────────────────────
export 'ui/interaction_animations.dart';

View File

@@ -52,7 +52,7 @@ class ChatAudioService {
return null;
}
if (kIsWeb) throw UnsupportedError('Web端不支持文件系统');
final appDocDir = await getApplicationDocumentsDirectory();
final appDocDir = await getApplicationSupportDirectory();
final audioDir = Directory('${appDocDir.path}/chat_audio');
if (!await audioDir.exists()) {
await audioDir.create(recursive: true);

View File

@@ -24,7 +24,7 @@ class ChatFileService {
/// 获取聊天附件根目录
static Future<Directory> getChatRootDir() async {
final appDir = await getApplicationDocumentsDirectory();
final appDir = await getApplicationSupportDirectory();
final chatDir = Directory('${appDir.path}/chat_attachments');
if (!chatDir.existsSync()) {
chatDir.createSync(recursive: true);
@@ -113,6 +113,9 @@ class ChatFileService {
/// 获取文件的绝对路径
/// 如果传入的已经是绝对路径或网络地址,直接返回
///
/// 兼容新旧两个路径:优先使用 Application Support新路径
/// 若文件不存在则回退到 Documents旧路径兼容迁移前残留文件
static Future<String> getAbsolutePath(String relativePath) async {
if (relativePath.startsWith('http') || relativePath.startsWith('/')) {
return relativePath;
@@ -121,8 +124,28 @@ class ChatFileService {
return relativePath;
}
if (kIsWeb) throw UnsupportedError('Web端不支持文件系统');
final appDir = await getApplicationDocumentsDirectory();
return '${appDir.path}/$relativePath';
// 优先使用新路径Application Support
try {
final supportDir = await getApplicationSupportDirectory();
final newPath = '${supportDir.path}/$relativePath';
if (await File(newPath).exists()) {
return newPath;
}
} catch (_) {}
// 回退到旧路径Documents兼容迁移前残留文件
try {
final docDir = await getApplicationDocumentsDirectory();
final oldPath = '${docDir.path}/$relativePath';
if (await File(oldPath).exists()) {
return oldPath;
}
} catch (_) {}
// 都不存在时返回新路径(让调用方处理不存在的情况)
final supportDir = await getApplicationSupportDirectory();
return '${supportDir.path}/$relativePath';
}
/// 判断是否为沙箱相对路径以chat_attachments/开头)

View File

@@ -58,7 +58,7 @@ class CacheManagerService {
];
Future<String> _getBasePath() async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
return '${dir.path}${Platform.pathSeparator}file_transfer';
}

View File

@@ -348,7 +348,7 @@ class CloudCacheService {
if (savePath != null) {
outputPath = savePath;
} else {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
outputPath =
'${dir.path}${Platform.pathSeparator}cloud_cache${Platform.pathSeparator}${record.fileName}';
await Directory(outputPath).parent.create(recursive: true);

View File

@@ -178,7 +178,7 @@ class FontManagementNotifier extends Notifier<FontManagementState>
Future<List<FontInfo>> _scanLocalFonts() async {
if (pu.isWeb) return [];
try {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final fontDir = Directory('${dir.path}/fonts');
final List<FontInfo> fonts = [];

View File

@@ -1,9 +1,13 @@
/// ============================================================
/// 闲言APP — 更多设置页面
/// 创建时间: 2026-05-08
/// 更新时间: 2026-06-05
/// 更新时间: 2026-06-26
/// 作用: 内容搜索、存储管理、数据管理等设置
/// 上次更新: Web兼容—path_provider调用添加kIsWeb保护
/// 上次更新: 修复"清空软件数据"在 macOS/Windows/Linux 非沙盒模式下误删用户公共
/// Documents 目录的严重 bug。原代码递归删除 getApplicationDocumentsDirectory()
/// 返回目录的所有子项,在 macOS 非沙盒下会删除 ~/Documents 下所有内容
/// (包括用户项目源代码)。现改为仅删除已知应用专属文件/子目录,并增加
/// 路径安全校验,禁止删除用户公共目录。
/// ============================================================
import 'dart:io';
@@ -14,6 +18,7 @@ import 'package:flutter/material.dart' show Divider;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -474,37 +479,24 @@ class _MoreSettingsPageState extends ConsumerState<MoreSettingsPage> {
Log.w('FactoryReset: 本地数据库清除失败', e);
}
// 6. 清除缓存文件(包括数据库文件
// 6. 清除应用专属缓存文件(安全清理:不删除用户公共目录
//
// ⚠️ 历史 bug 修复2026-06-26
// 原代码遍历 getApplicationDocumentsDirectory() 返回目录的所有子项并递归删除,
// 在 macOS/Windows/Linux 非沙盒模式下,该路径是用户公共 ~/Documents 目录,
// 递归删除会导致用户项目源代码、其他文档全部丢失。
//
// 修复策略:
// - 仅删除已知应用专属文件xianyan.db 及其 WAL/SHM 临时文件)
// - 仅在路径被验证为应用专属时清理子目录内容
// - 永不递归删除 getApplicationDocumentsDirectory() 返回目录的根内容
try {
if (kIsWeb) {
// Web端无文件系统跳过
} else {
final dirs = <Directory>[];
try {
dirs.add(await getTemporaryDirectory());
} catch (_) {}
try {
dirs.add(await getApplicationDocumentsDirectory());
} catch (_) {}
try {
dirs.add(await getApplicationSupportDirectory());
} catch (_) {}
for (final dir in dirs) {
if (await dir.exists()) {
await for (final entity in dir.list()) {
try {
if (entity is File) {
await entity.delete();
} else if (entity is Directory) {
await entity.delete(recursive: true);
}
} catch (_) {}
}
}
}
await _safeClearAppFilesystemData();
}
Log.i('FactoryReset: 缓存文件已清除');
Log.i('FactoryReset: 应用专属缓存文件已清除');
} catch (e) {
Log.w('FactoryReset: 缓存文件清除失败', e);
}
@@ -564,4 +556,187 @@ class _MoreSettingsPageState extends ConsumerState<MoreSettingsPage> {
}
}
}
// ============================================================
// 安全清理应用文件系统数据
// ============================================================
/// 应用 bundle id用于校验路径是否为应用专属
static const _appBundleId = 'apps.xy.xianyan';
/// 应用数据库名称(与 native.dart 中保持一致)
static const _appDbName = 'xianyan.db';
/// 安全清理应用文件系统数据
///
/// 核心安全原则:
/// 1. **永不递归删除** `getApplicationDocumentsDirectory()`、
/// `getApplicationSupportDirectory()` 等返回目录的根内容
/// - macOS/Windows/Linux 非沙盒模式下,这些路径可能是用户公共目录
/// (如 `~/Documents`、`~/Library/Application Support`
/// - 递归删除会破坏用户其他文件和项目源代码
/// 2. **仅删除已知应用专属路径**
/// - 数据库文件:`<docs>/xianyan.db`、`<docs>/xianyan.db-wal`、`<docs>/xianyan.db-shm`
/// - 应用专属子目录:路径中包含 bundle_id 或应用名的目录
/// 3. **路径校验**:清理子目录前验证路径包含应用标识,否则跳过
Future<void> _safeClearAppFilesystemData() async {
// ---- 6.1 删除数据库文件(精确文件,不递归父目录)----
// 数据库连接已在步骤 4 关闭,文件可安全删除
// 同时清理新旧两个路径:新路径为 getApplicationSupportDirectory()
// 旧路径为 getApplicationDocumentsDirectory()(兼容历史版本残留文件)
final dbSearchDirs = <Directory>[];
try {
dbSearchDirs.add(await getApplicationSupportDirectory());
} catch (_) {}
try {
dbSearchDirs.add(await getApplicationDocumentsDirectory());
} catch (_) {}
for (final dir in dbSearchDirs) {
try {
final dirPath = dir.path;
// 仅删除明确的应用数据库文件,绝不遍历目录
for (final suffix in ['', '-wal', '-shm', '-journal']) {
final dbFile = File(p.join(dirPath, '$_appDbName$suffix'));
if (await dbFile.exists()) {
try {
await dbFile.delete();
Log.i('FactoryReset: 已删除 ${dir.path}/$_appDbName$suffix');
} catch (e) {
Log.w('FactoryReset: 删除 $_appDbName$suffix 失败', e);
}
}
}
} catch (e) {
Log.w('FactoryReset: 数据库文件清理失败 (${dir.path})', e);
}
}
// ---- 6.2 清理临时目录(应用专属,可安全清空内容)----
// getTemporaryDirectory() 在所有平台均返回应用专属缓存目录:
// - Android: /data/data/<pkg>/cache 或 getCacheDir()
// - iOS: <app>/Library/Caches
// - macOS 沙盒: ~/Library/Containers/<bundle>/Data/Library/Caches
// - macOS 非沙盒: ~/Library/Caches/<bundle>
// - Windows: %TEMP% 或 <app>/cache
// 因此清空其内容是安全的
try {
final tmpDir = await getTemporaryDirectory();
await _safeClearDirectoryContents(tmpDir, label: '临时目录');
} catch (e) {
Log.w('FactoryReset: 临时目录清理失败', e);
}
// ---- 6.3 清理应用支持目录(带路径校验)----
// getApplicationSupportDirectory() 各平台返回值:
// - macOS 沙盒: ~/Library/Containers/<bundle>/Data/Library/Application Support
// (应用专属,可安全清空内容)
// - macOS 非沙盒: ~/Library/Application Support/<bundle>
// (应用专属子目录,可安全清空内容)
// - Windows: %APPDATA%/<vendor>/<app>(应用专属)
// - Linux: ~/.local/share/<app>(应用专属)
// - iOS: <app>/Library/Application Support应用专属
// 即使如此,仍进行路径校验,仅当路径包含 bundle_id 或应用名时才清空
try {
final supportDir = await getApplicationSupportDirectory();
await _safeClearDirectoryContents(
supportDir,
label: '应用支持目录',
requireAppIdentifier: true,
);
} catch (e) {
Log.w('FactoryReset: 应用支持目录清理失败', e);
}
// ---- 6.4 清理应用缓存目录(如果平台支持)----
// getApplicationCacheDirectory() 是更明确的缓存目录,可安全清空
try {
final cacheDir = await getApplicationCacheDirectory();
await _safeClearDirectoryContents(cacheDir, label: '应用缓存目录');
} catch (e) {
// 部分平台可能不支持,忽略
}
// ---- 6.5 清理 Documents 目录下已知应用子目录(不删除根内容)----
// 兼容迁移失败时残留的应用数据子目录
// 永不递归删除 getApplicationDocumentsDirectory() 返回目录的根内容
try {
final docDir = await getApplicationDocumentsDirectory();
const appSubdirs = [
'image_cache',
'readlater_sync',
'chat_audio',
'chat_attachments',
'chat_trash',
'cloud_cache',
'file_transfer',
'fonts',
'xianyan_backups',
];
for (final subdirName in appSubdirs) {
try {
final subdir = Directory(p.join(docDir.path, subdirName));
if (await subdir.exists()) {
await subdir.delete(recursive: true);
Log.i('FactoryReset: 已删除 Documents/$subdirName');
}
} catch (_) {}
}
} catch (_) {}
// ---- 6.6 清理 Flutter 图片缓存 ----
try {
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
} catch (e) {
Log.w('FactoryReset: Flutter 图片缓存清理失败', e);
}
}
/// 安全清空目录内容(不删除目录本身)
///
/// [requireAppIdentifier] 为 true 时,会先校验目录路径是否包含应用标识
/// bundle_id 或应用名),若不包含则跳过清理,避免误删用户公共目录内容。
///
/// 该方法仅清空目录的**直接子项**,对子目录使用递归删除。
/// 不会删除目录本身。
Future<void> _safeClearDirectoryContents(
Directory dir, {
required String label,
bool requireAppIdentifier = false,
}) async {
try {
if (!await dir.exists()) return;
// 路径安全校验:若要求应用标识,检查路径是否包含 bundle_id 或应用名
if (requireAppIdentifier) {
final dirPath = dir.path.toLowerCase();
final isAppSpecific = dirPath.contains(_appBundleId.toLowerCase()) ||
dirPath.contains('xianyan');
if (!isAppSpecific) {
Log.w('FactoryReset: $label 路径未包含应用标识,跳过清理: ${dir.path}');
return;
}
}
var deletedCount = 0;
await for (final entity in dir.list()) {
try {
if (entity is File) {
await entity.delete();
deletedCount++;
} else if (entity is Directory) {
await entity.delete(recursive: true);
deletedCount++;
}
} catch (e) {
// 单个文件删除失败不影响整体清理
Log.w('FactoryReset: 删除 ${entity.path} 失败', e);
}
}
Log.i('FactoryReset: $label 已清理 $deletedCount 个项目');
} catch (e) {
Log.w('FactoryReset: 清理 $label 失败', e);
}
}
}

View File

@@ -974,37 +974,93 @@ class GeneralSettingsNotifier extends Notifier<GeneralSettingsState> {
Future<void> clearCache() async {
try {
if (kIsWeb) return;
final dirs = <String>[];
// 安全清理:避免递归删除 getApplicationDocumentsDirectory() 返回目录的根内容
// 历史 bug在 macOS/Windows/Linux 非沙盒下,该目录为用户公共 ~/Documents
// 递归删除会丢失用户项目源代码和其他文档
const appBundleId = 'apps.xy.xianyan';
// 1. 清空临时目录(应用专属,安全)
try {
dirs.add((await getTemporaryDirectory()).path);
} catch (_) {}
try {
dirs.add((await getApplicationDocumentsDirectory()).path);
} catch (_) {}
try {
dirs.add((await getApplicationSupportDirectory()).path);
final tmpDir = await getTemporaryDirectory();
await _safeClearDirContents(tmpDir, '临时目录', requireAppId: false);
} catch (_) {}
for (final path in dirs) {
final dir = Directory(path);
if (await dir.exists()) {
await for (final entity in dir.list()) {
try {
if (entity is File) {
await entity.delete();
} else if (entity is Directory) {
await entity.delete(recursive: true);
}
} catch (_) {}
}
// 2. 清空应用支持目录(应用专属,带路径校验)
try {
final supportDir = await getApplicationSupportDirectory();
await _safeClearDirContents(supportDir, '应用支持目录',
requireAppId: true, appId: appBundleId);
} catch (_) {}
// 3. 清理 Documents 目录下已知应用子目录(不删除根内容)
try {
final docDir = await getApplicationDocumentsDirectory();
const appSubdirs = [
'image_cache',
'readlater_sync',
'chat_audio',
'chat_attachments',
'chat_trash',
'cloud_cache',
'file_transfer',
'fonts',
'xianyan_backups',
];
for (final subdirName in appSubdirs) {
try {
final subdir = Directory('${docDir.path}/$subdirName');
if (await subdir.exists()) {
await subdir.delete(recursive: true);
Log.i('clearCache: 已删除 Documents/$subdirName');
}
} catch (_) {}
}
}
} catch (_) {}
state = state.copyWith(general: state.general.copyWith(cacheSize: 0));
Log.i('缓存已清除');
} catch (e) {
Log.e('缓存清除失败', e);
}
}
/// 安全清空目录内容(不删除目录本身)
///
/// [requireAppId] 为 true 时,校验目录路径是否包含应用标识,避免误删用户公共目录
Future<void> _safeClearDirContents(
Directory dir,
String label, {
required bool requireAppId,
String appId = '',
}) async {
try {
if (!await dir.exists()) return;
if (requireAppId) {
final dirPath = dir.path.toLowerCase();
final isAppSpecific = dirPath.contains(appId.toLowerCase()) ||
dirPath.contains('xianyan');
if (!isAppSpecific) {
Log.w('clearCache: $label 路径未包含应用标识,跳过清理: ${dir.path}');
return;
}
}
await for (final entity in dir.list()) {
try {
if (entity is File) {
await entity.delete();
} else if (entity is Directory) {
await entity.delete(recursive: true);
}
} catch (_) {}
}
Log.i('clearCache: $label 已清空');
} catch (e) {
Log.w('clearCache: 清理 $label 失败', e);
}
}
}
final generalSettingsProvider =

View File

@@ -67,9 +67,9 @@ class FontDownloadService {
if (pu.isWeb) throw UnsupportedError('Web端不支持字体管理');
Directory dir;
try {
dir = await getApplicationDocumentsDirectory();
dir = await getApplicationSupportDirectory();
} catch (e) {
Log.w('获取应用文档目录失败,降级使用临时目录', e);
Log.w('获取应用支持目录失败,降级使用临时目录', e);
dir = Directory.systemTemp;
}
final fontDir = Directory('${dir.path}/fonts');

View File

@@ -164,6 +164,15 @@ Future<void> _appMain() async {
Log.e('AppVersion 初始化失败', e, st, LogCategory.general);
}
// 应用数据目录迁移Documents → Application Support
// 必须在 KvStorage/Hive 初始化之前执行,确保存储初始化时使用新路径
// 详见 path_provider_native.dart 中的 migrateAppDataFromDocumentsToSupport()
try {
await pu.migrateAppData();
} catch (e, st) {
Log.e('应用数据迁移失败', e, st, LogCategory.storage);
}
try {
await KvStorage.init();
kvStorageReady = true;

View File

@@ -2,8 +2,14 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 启用沙盒:让 Debug 模式与 Release 模式路径行为一致,避免开发环境差异
导致的 bug如 getApplicationDocumentsDirectory() 在非沙盒下返回用户公共
~/Documents 目录,曾导致 FactoryReset 误删项目源代码)。
代价Debug 模式下旧版数据库(位于 ~/Documents/xianyan.db 或
~/Library/Application Support/apps.xy.xianyan/xianyan.db无法迁移到
沙盒内,开发者需重新登录/创建数据。 -->
<key>com.apple.security.app-sandbox</key>
<false/>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
@@ -21,7 +27,7 @@
<!-- 麦克风:语音录制 / 转文字AVCaptureDevice via PermissionManager.swift -->
<key>com.apple.security.device.microphone</key>
<true/>
<!-- 定位permission_handler 依赖链引用 CoreLocation 符号,沙盒需此 entitlement -->
<!-- 定位permission_handler 依赖链引用 CoreLocation沙盒需此 entitlement -->
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>