Compare commits
25
Commits
e09b0567d6
...
v26.9.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c1cc250e | ||
|
|
79c43d5340 | ||
|
|
8f853f7ef3 | ||
|
|
4f574cb5fa | ||
|
|
10a95ddeea | ||
|
|
069aa58c9f | ||
|
|
8e940f164b | ||
|
|
272246fb61 | ||
|
|
78d272859c | ||
|
|
70d2d4e1d0 | ||
|
|
706b32e1f2 | ||
|
|
3664120808 | ||
|
|
ffa410b399 | ||
|
|
d702ed0d31 | ||
|
|
27ad5d89a5 | ||
|
|
6b9f71da08 | ||
|
|
28e0c4664a | ||
|
|
d21649c60e | ||
|
|
4dd60f42a1 | ||
|
|
0a19b4b38a | ||
|
|
7d49a7395f | ||
|
|
d0705b1ffe | ||
|
|
6c7897bf47 | ||
|
|
2f20161010 | ||
|
|
d09599fa95 |
@@ -7,6 +7,12 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
.trae
|
||||||
|
.workbuddy
|
||||||
|
temp/
|
||||||
|
release_stage/
|
||||||
|
musicdl_outputs/
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
@@ -1,618 +0,0 @@
|
|||||||
# 模块开发指南
|
|
||||||
|
|
||||||
本文档详细说明 Thing 应用的模块系统架构,帮助开发者快速理解并创建新模块。
|
|
||||||
|
|
||||||
## 架构概述
|
|
||||||
|
|
||||||
Thing 采用**统一模块注册机制**,每个模块通过 `index.ts` 自描述其全部配置(名称、图标、组件、搜索项、进程配置、生命周期钩子等),由中央注册表 `moduleRegistry` 统一管理。
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ main.ts │
|
|
||||||
│ import './modules' (触发注册) │
|
|
||||||
└──────────────────────┬──────────────────────────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ src/modules/index.ts │
|
|
||||||
│ 聚合入口:导入所有模块配置并注册 │
|
|
||||||
└──────────────────────┬──────────────────────────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ src/modules/registry.ts │
|
|
||||||
│ ModuleRegistry 单例:注册 / 查询 / 组件懒加载 │
|
|
||||||
└──────┬───────────────┬───────────────┬──────────────┘
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
App.vue Sidebar.vue GeneralSettings
|
|
||||||
(组件加载) (侧边栏导航) (模块开关管理)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 核心文件说明
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `src/types/module.ts` | 模块配置类型定义 |
|
|
||||||
| `src/modules/registry.ts` | 注册表单例:注册、查询、组件懒加载缓存 |
|
|
||||||
| `src/modules/index.ts` | 聚合入口:导入并注册所有模块 |
|
|
||||||
| `src/modules/icons.ts` | 模块图标映射表 |
|
|
||||||
| `src/modules/<name>/index.ts` | 各模块的配置声明 |
|
|
||||||
| `src/modules/<name>/*.vue` | 模块前端组件 |
|
|
||||||
|
|
||||||
## 模块配置类型
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/types/module.ts
|
|
||||||
|
|
||||||
interface ModuleConfig {
|
|
||||||
/** 模块唯一标识(如 'proxy'、'clipboard') */
|
|
||||||
id: string
|
|
||||||
/** 显示名称 */
|
|
||||||
name: string
|
|
||||||
/** 图标标识(对应 icons.ts 中的 key) */
|
|
||||||
icon: string
|
|
||||||
/** 模块描述(显示在设置界面的模块管理中) */
|
|
||||||
description: string
|
|
||||||
/** 模块分类 */
|
|
||||||
category: 'network' | 'tool' | 'system' | 'media'
|
|
||||||
/** 默认是否启用(默认 true) */
|
|
||||||
defaultEnabled?: boolean
|
|
||||||
/** 是否为内置模块(不可禁用,如设置模块) */
|
|
||||||
builtin?: boolean
|
|
||||||
/** 懒加载组件的 loader 函数(推荐) */
|
|
||||||
loader?: () => Promise<{ default: Component }>
|
|
||||||
/** 直接组件引用(内置模块可用,无需懒加载) */
|
|
||||||
component?: Component
|
|
||||||
/** 全局搜索项 */
|
|
||||||
searchItems?: SearchIndexItem[]
|
|
||||||
/** 进程配置(需要管理子进程的模块填写) */
|
|
||||||
process?: ModuleProcessConfig
|
|
||||||
/** 生命周期钩子 */
|
|
||||||
lifecycle?: ModuleLifecycle
|
|
||||||
/** 排序权重(数值越小越靠前,默认 100) */
|
|
||||||
order?: number
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 创建新模块
|
|
||||||
|
|
||||||
### 第 1 步:创建模块目录
|
|
||||||
|
|
||||||
```
|
|
||||||
src/modules/my-module/
|
|
||||||
├── index.ts # 模块配置
|
|
||||||
└── MyModule.vue # 前端组件
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 2 步:编写模块配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/modules/my-module/index.ts
|
|
||||||
import type { ModuleConfig } from '@/types/module'
|
|
||||||
|
|
||||||
export const moduleConfig: ModuleConfig = {
|
|
||||||
id: 'my-module',
|
|
||||||
name: '我的模块',
|
|
||||||
icon: 'my-module', // 需在 icons.ts 中添加映射
|
|
||||||
description: '模块功能描述',
|
|
||||||
category: 'tool',
|
|
||||||
defaultEnabled: true,
|
|
||||||
loader: () => import('./MyModule.vue'),
|
|
||||||
searchItems: [
|
|
||||||
{
|
|
||||||
title: '功能名称',
|
|
||||||
description: '功能描述',
|
|
||||||
keywords: ['关键词1', '关键词2', 'keyword']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
order: 70
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 3 步:注册模块
|
|
||||||
|
|
||||||
在 `src/modules/index.ts` 中添加导入:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { moduleConfig as myModule } from './my-module'
|
|
||||||
|
|
||||||
const allModules: ModuleConfig[] = [
|
|
||||||
// ... 已有模块
|
|
||||||
myModule
|
|
||||||
]
|
|
||||||
|
|
||||||
moduleRegistry.registerAll(allModules)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 4 步:添加图标映射
|
|
||||||
|
|
||||||
在 `src/modules/icons.ts` 中添加:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Wrench } from '@lucide/vue' // 选择合适的图标
|
|
||||||
|
|
||||||
export const moduleIconMap: Record<string, Component> = {
|
|
||||||
// ... 已有映射
|
|
||||||
'my-module': Wrench
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 5 步(可选):添加 Rust 后端命令
|
|
||||||
|
|
||||||
如果模块需要 Rust 后端支持,在 `src-tauri/src/` 中创建命令模块,并在 `lib.rs` 的 `invoke_handler` 中注册。
|
|
||||||
|
|
||||||
## 进程管理
|
|
||||||
|
|
||||||
需要管理外部子进程的模块(如 mihomo)通过 `ModuleProcessConfig` 声明进程配置。下载器模块使用进程内自建下载引擎,不涉及外部子进程管理。
|
|
||||||
|
|
||||||
### 配置示例
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/modules/proxy/index.ts
|
|
||||||
export const moduleConfig: ModuleConfig = {
|
|
||||||
// ...
|
|
||||||
process: {
|
|
||||||
name: 'mihomo', // 进程标识
|
|
||||||
executable: '', // 可执行文件路径(运行时确定)
|
|
||||||
args: ['-f', 'config.yaml'], // 启动参数
|
|
||||||
autoStart: false, // 模块启用时是否自动启动
|
|
||||||
restartOnCrash: true, // 崩溃后自动重启
|
|
||||||
maxRestarts: 3 // 最大重启次数(0 = 不限制)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端 API
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useProcessStore } from '@/stores/processStore'
|
|
||||||
|
|
||||||
const processStore = useProcessStore()
|
|
||||||
|
|
||||||
// 通过模块 ID 启动进程(自动读取模块配置)
|
|
||||||
await processStore.startByModule('proxy')
|
|
||||||
|
|
||||||
// 停止进程
|
|
||||||
await processStore.stopByModule('proxy')
|
|
||||||
|
|
||||||
// 获取进程状态(从缓存读取)
|
|
||||||
const status = processStore.getProcessStatus('proxy')
|
|
||||||
// status.status: 'running' | 'stopped' | 'crashed' | 'starting'
|
|
||||||
|
|
||||||
// 刷新所有进程状态
|
|
||||||
await processStore.refreshAll()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自动化行为
|
|
||||||
|
|
||||||
模块启用/禁用时,`appStore.toggleModule` 会自动处理:
|
|
||||||
|
|
||||||
| 操作 | 禁用模块 | 启用模块 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| 进程 | 停止运行中的进程 | 若 `autoStart` 为 true,启动进程 |
|
|
||||||
| 搜索项 | 移除该模块的搜索项 | 恢复该模块的搜索项 |
|
|
||||||
| 组件缓存 | 清除组件缓存,释放内存 | 下次访问时重新懒加载 |
|
|
||||||
| 生命周期 | 调用 `onDisable` 钩子 | 调用 `onEnable` 钩子 |
|
|
||||||
| 侧边栏 | 从侧边栏隐藏 | 在侧边栏显示 |
|
|
||||||
| Toast 通知 | 显示"已禁用 {模块名}" | 显示"已启用 {模块名}" |
|
|
||||||
|
|
||||||
## 生命周期钩子
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ModuleLifecycle {
|
|
||||||
/** 模块首次加载时调用 */
|
|
||||||
onInit?: () => void | Promise<void>
|
|
||||||
/** 模块组件挂载时调用(切换到该模块) */
|
|
||||||
onActivate?: () => void | Promise<void>
|
|
||||||
/** 模块组件卸载时调用(切换离开该模块) */
|
|
||||||
onDeactivate?: () => void | Promise<void>
|
|
||||||
/** 模块被禁用时调用 */
|
|
||||||
onDisable?: () => void | Promise<void>
|
|
||||||
/** 模块被启用时调用 */
|
|
||||||
onEnable?: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const moduleConfig: ModuleConfig = {
|
|
||||||
// ...
|
|
||||||
lifecycle: {
|
|
||||||
onEnable: async () => {
|
|
||||||
console.log('模块已启用')
|
|
||||||
// 初始化资源、建立连接等
|
|
||||||
},
|
|
||||||
onDisable: async () => {
|
|
||||||
console.log('模块已禁用')
|
|
||||||
// 释放资源、关闭连接等
|
|
||||||
},
|
|
||||||
onActivate: () => {
|
|
||||||
console.log('用户切换到本模块')
|
|
||||||
// 开始实时数据更新等
|
|
||||||
},
|
|
||||||
onDeactivate: () => {
|
|
||||||
console.log('用户离开本模块')
|
|
||||||
// 暂停实时更新以节省资源
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 全局搜索
|
|
||||||
|
|
||||||
模块通过 `searchItems` 声明可被全局搜索的功能项。用户在标题栏搜索框输入关键词时,匹配的搜索项会显示在结果列表中。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
searchItems: [
|
|
||||||
{
|
|
||||||
title: '代理设置', // 显示标题
|
|
||||||
description: '配置网络代理', // 显示描述
|
|
||||||
keywords: ['代理', 'proxy', '网络'] // 匹配关键词
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
如果搜索项需要执行特定操作(如跳转到子页面、触发命令),在模块组件挂载时通过 `searchStore.registerAction` 注册:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useSearchStore } from '@/stores/searchStore'
|
|
||||||
|
|
||||||
const searchStore = useSearchStore()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 注册第 0 个搜索项的 action
|
|
||||||
searchStore.registerAction('my-module', 0, () => {
|
|
||||||
// 跳转到特定子页面或执行操作
|
|
||||||
activeTab.value = 'settings'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## 模块分类
|
|
||||||
|
|
||||||
| 分类 | 说明 | 适用场景 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `network` | 网络相关 | 代理、下载器、网络工具 |
|
|
||||||
| `tool` | 实用工具 | 剪贴板、文件搜索、文本处理 |
|
|
||||||
| `system` | 系统相关 | 硬件监控、系统设置 |
|
|
||||||
| `media` | 媒体相关 | 截图、录屏、图片处理 |
|
|
||||||
|
|
||||||
分类目前用于元信息标记,未来可用于设置界面的分组展示。
|
|
||||||
|
|
||||||
## 模块禁用机制详解
|
|
||||||
|
|
||||||
当用户在设置页面切换模块开关时:
|
|
||||||
|
|
||||||
```
|
|
||||||
用户点击开关
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
appStore.toggleModule(moduleId, enabled)
|
|
||||||
│
|
|
||||||
├─ 禁用时:
|
|
||||||
│ ├─ searchStore.unregisterModule(moduleId) // 移除搜索项
|
|
||||||
│ ├─ processStore.stopByModule(moduleId) // 停止进程
|
|
||||||
│ ├─ config.lifecycle?.onDisable?.() // 生命周期钩子
|
|
||||||
│ ├─ moduleRegistry.clearComponentCache(id) // 清除组件缓存
|
|
||||||
│ ├─ modules[id].enabled = false // 更新状态
|
|
||||||
│ └─ toast.success('已禁用 {模块名}') // 通知用户
|
|
||||||
│
|
|
||||||
└─ 启用时:
|
|
||||||
├─ modules[id].enabled = true // 更新状态
|
|
||||||
├─ config.lifecycle?.onEnable?.() // 生命周期钩子
|
|
||||||
├─ searchStore.registerItem(...) // 恢复搜索项
|
|
||||||
├─ processStore.startByModule(moduleId) // 启动进程(若 autoStart)
|
|
||||||
└─ toast.success('已启用 {模块名}') // 通知用户
|
|
||||||
```
|
|
||||||
|
|
||||||
禁用后的效果:
|
|
||||||
- 模块从侧边栏导航中隐藏
|
|
||||||
- 模块的搜索项从全局搜索中移除
|
|
||||||
- 模块的后台进程被停止
|
|
||||||
- 模块的组件缓存被清除,释放内存
|
|
||||||
- 如果当前正在查看被禁用的模块,自动切换到第一个可用模块
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **模块 ID 必须唯一**:重复注册会被忽略并输出警告
|
|
||||||
2. **图标必须映射**:模块配置中的 `icon` 字符串必须在 `icons.ts` 中有对应映射,否则回退到 Settings 图标
|
|
||||||
3. **懒加载优先**:使用 `loader` 而非 `component`,避免首屏加载所有模块代码
|
|
||||||
4. **避免循环依赖**:模块的 `index.ts` 只导出配置,不导入其他模块的 store
|
|
||||||
5. **进程配置的 executable**:通常留空,由模块组件在运行时根据用户设置确定实际路径
|
|
||||||
6. **内置模块**:设置 `builtin: true` 的模块不可被用户禁用,开关处于禁用状态
|
|
||||||
|
|
||||||
## 跨模块开发经验(代理模块沉淀)
|
|
||||||
|
|
||||||
以下要点来自代理模块开发,对后续涉及子进程管理、外部 API 交互、shadcn-vue 组件使用的模块同样适用。
|
|
||||||
|
|
||||||
### Tauri 命令与主线程
|
|
||||||
|
|
||||||
- **同步命令(`pub fn`)会阻塞主线程**:Tauri 的同步命令在主线程执行,其内部的 `std::thread::sleep`、磁盘 I/O、网络请求会阻塞所有 async 命令的调度。涉及等待/阻塞操作的命令必须声明为 `pub async fn`,并用 `tauri::async_runtime::spawn_blocking(|| { std::thread::sleep(...) }).await` 将阻塞操作放到线程池。
|
|
||||||
- **Windows 端口释放有延迟**:`child.kill()` + `child.wait()` 后 TCP 端口不会立即可用,需等待约 800ms 再重新绑定。重启类命令应预留此延迟。
|
|
||||||
- **`Mutex` 持锁期间禁止 sleep**:`ProcessManager::check_and_cleanup` 等持锁函数中不要执行长时间 sleep,否则会阻塞所有需要该锁的命令(如状态查询)。应先释放锁再 sleep,或移出临界区。
|
|
||||||
- **进程监控线程**:`start_monitoring_thread` 每 3 秒检查一次进程状态,崩溃时自动重启(可配置 `maxRestarts`)。前端通过监听 `process-status-changed` 事件更新 UI。
|
|
||||||
|
|
||||||
### 外部 API 交互
|
|
||||||
|
|
||||||
- **API 就绪轮询**:子进程 spawn 后 API 不会立即可用(需初始化配置、加载 geo 文件等)。前端应在请求前轮询健康检查接口(如 `/version`),500ms 间隔、10s 超时。
|
|
||||||
- **缓存配置避免频繁读盘**:后端 Manager 每次方法调用都从磁盘读 settings 会拖慢批量操作。建议在 Manager 内维护内存缓存,`save_settings` 时同步更新。
|
|
||||||
- **并发请求限流**:批量测速等场景不要一次性 `Promise.all` 全部请求,应分批(如每批 20 个),避免压垮子进程。
|
|
||||||
|
|
||||||
### shadcn-vue / reka-ui 注意事项
|
|
||||||
|
|
||||||
- **Select 禁止空字符串 value**:`<SelectItem value="">` 会触发警告并失效。使用哨兵值(如 `__default__`、`__all__`)替代空字符串,在 `@update:model-value` 回调中转回空值。
|
|
||||||
- **Select 双击问题**:reka-ui Select 的 DismissableLayer + closeAutoFocus 会导致连续点击两个 Select 时第一次点击仅关闭上一个、需第二次点击才打开下一个。当前未完美解决,建议同一界面避免放置过多相邻 Select。
|
|
||||||
- **Switch 使用 `model-value` / `update:model-value`**:reka-ui v2+ 的 Switch 不再使用 `checked` / `update:checked`。
|
|
||||||
- **Sonner toast 不可见**:通常是缺少 `vue-sonner/lib/index.css` 导入,而非 z-index 问题。确保在入口处导入该 CSS。
|
|
||||||
- **AlertDialog 替代 confirm()**:原生 `confirm()` 在 Tauri WebView 中样式不一致,使用 shadcn-vue AlertDialog 封装 Promise 化的 `showConfirm()` 函数,支持 destructive 样式。
|
|
||||||
|
|
||||||
### 数据一致性与自愈
|
|
||||||
|
|
||||||
- **配置文件与磁盘 reconcile**:settings.json 中的列表(如订阅 profiles)可能与磁盘文件不同步(用户手动删除、反序列化失败被 default 覆盖)。每次 `load_settings` 时应扫描磁盘补全缺失条目,并在 `currentProfile` 为 null 时自动指向第一个。
|
|
||||||
- **操作顺序防重复**:导入文件后写入 settings 时,若先写文件再 load_settings(含 reconcile 扫盘),reconcile 会扫到新文件添加一次,随后 push 又添加一次。应先 load_settings → 写文件 → push(加去重保护)。
|
|
||||||
- **子进程崩溃循环防护**:子进程因依赖文件损坏(如 geo 数据库)崩溃时,`restart_on_crash` 会反复重启。需配置备用下载源(如 jsdelivr 镜像)避免无代理时 GitHub 超时。
|
|
||||||
|
|
||||||
### UI 细节
|
|
||||||
|
|
||||||
- **瀑布流布局避免卡片等高撑开**:使用 `columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid` 替代 `grid`,让卡片按内容高度自然排列。
|
|
||||||
- **模块禁用清理系统状态**:模块 `onDisable` 钩子应清理系统级副作用(如系统代理注册表项),避免模块停用后遗留导致系统异常。
|
|
||||||
- **应用退出清理**:在 `lib.rs` 的 `quit_app` 命令和托盘退出事件中都要调用 `cleanup_on_exit` + `stop_all`,确保任何退出路径都清理干净。
|
|
||||||
|
|
||||||
## 近期重要变更(影响其他模块开发)
|
|
||||||
|
|
||||||
以下是最近几次改动中确立的约定和模式,开发新模块时需遵循。
|
|
||||||
|
|
||||||
### 后端:子进程启动必须隐藏控制台窗口
|
|
||||||
|
|
||||||
`process_manager.rs` 暴露了公共函数 `setup_creation_flags(cmd: &mut Command)`,Windows 上会设置 `CREATE_NO_WINDOW` 标志。
|
|
||||||
|
|
||||||
**所有用 `std::process::Command` 启动外部程序的地方都必须调用它**,否则会弹出黑色控制台窗口(即使程序是后台运行)。包括:
|
|
||||||
|
|
||||||
- `ProcessManager::start()` 启动 mihomo 等内核
|
|
||||||
- `ProcessManager::check_and_cleanup()` 崩溃重启
|
|
||||||
- 任何调用 `mihomo.exe -v` 等查询版本的场景
|
|
||||||
|
|
||||||
跨平台:非 Windows 平台该函数为空实现,无需条件编译。
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use crate::process_manager::setup_creation_flags;
|
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new(&path);
|
|
||||||
cmd.arg("-v");
|
|
||||||
setup_creation_flags(&mut cmd); // 必须调用
|
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端:长时间 wait() 不要阻塞主线程
|
|
||||||
|
|
||||||
`ProcessManager::stop()` 中 `child.kill()` 是快速的,但 `child.wait()` 可能阻塞几十到几百毫秒。**wait() 已移到后台线程**:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
thread::spawn(move || {
|
|
||||||
let _ = entry.child.wait();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
新模块若有类似的"终止外部进程"逻辑,也应遵循此模式,避免 Tauri 命令阻塞导致前端卡顿。
|
|
||||||
|
|
||||||
### 后端:内核/二进制下载用流式 + 事件推送
|
|
||||||
|
|
||||||
`mihomo_manager/` 目录(`kernel.rs`)的 `install_kernel` 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用:
|
|
||||||
|
|
||||||
- **流式下载**:`reqwest::Response::bytes_stream()` + `futures_util::StreamExt`,避免大文件一次性读入内存
|
|
||||||
- **进度事件**:通过 `app.emit("xxx-install-progress", progress)` 推送,事件载荷结构参考 `InstallProgress`
|
|
||||||
- **事件节流**:仅在百分比变化 ≥1% 时 emit,避免事件轰炸
|
|
||||||
- **zip 解压**:用 `zip` crate(纯 Rust),不要用 PowerShell `Expand-Archive`(有执行策略问题)
|
|
||||||
- **文件名匹配**:解压后用 `find_exe_in_dir` 查找 `.exe`(zip 内文件名可能含版本号,不是固定名字),找到后重命名为标准名
|
|
||||||
|
|
||||||
### 后端:GitHub API rate limit 规避
|
|
||||||
|
|
||||||
`check_kernel_update` 采用 **API 优先 + 重定向回退** 策略:
|
|
||||||
|
|
||||||
1. 先调 `api.github.com/.../releases/latest`(能拿完整资产列表,命名变化时更健壮)
|
|
||||||
2. 失败(403 rate limit / 网络错误)时回退到访问 `github.com/.../releases/latest`,从 302 重定向的最终 URL 提取版本号,按稳定命名规则构造下载 URL
|
|
||||||
|
|
||||||
**新模块若需要查 GitHub 最新版本,应复用此模式**,不要直接调 API(未认证 60次/小时/IP 极易超限)。
|
|
||||||
|
|
||||||
### 后端:资产命名规则适配
|
|
||||||
|
|
||||||
mihomo v1.19+ 改了 Windows 资产命名,按 CPU 微架构分级:
|
|
||||||
|
|
||||||
- 旧:`mihomo-windows-amd64-vX.X.X.zip`(已废弃)
|
|
||||||
- 新:`mihomo-windows-amd64-v3-vX.X.X.zip`(v1/v2/v3 对应 CPU level)
|
|
||||||
|
|
||||||
`fetch_latest_via_api` 中的匹配优先级:v3 标准 > v3-go124 > v3-go123 > v3 其他 > v2 > v1 > 旧命名。若未来其他内核也有类似分级,参考此优先级策略。
|
|
||||||
|
|
||||||
### 前端:Transition 内的 v-if/v-else 必须加 key
|
|
||||||
|
|
||||||
**这是 dev 模式的坑**。`<Transition mode="out-in">` 内的 `v-if`/`v-else` 分支如果缺 `:key`,Vue 3.5.x dev 模式下会触发 `__vnode` 写入竞态,导致:
|
|
||||||
|
|
||||||
- 控制台报错 `Cannot set properties of null (setting '__vnode')`
|
|
||||||
- vnode 树损坏,所有事件派发失效(按钮点击没反应)
|
|
||||||
- **build 模式不报错**(生产构建剥除了 `__vnode` instrumentation),容易漏掉
|
|
||||||
|
|
||||||
**约定**:`<Transition>` 内所有分支(v-if/v-else-if/v-else)都必须加 `:key`,即使是原生 div 也要加。
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<Transition name="fade" mode="out-in">
|
|
||||||
<ComponentA v-if="cond" key="a" />
|
|
||||||
<div v-else key="empty">占位</div>
|
|
||||||
</Transition>
|
|
||||||
```
|
|
||||||
|
|
||||||
`ModuleContainer.vue` 和 `ProxyModule.vue` 的 Progress 区块已修复,新模块开发时注意。
|
|
||||||
|
|
||||||
### 前端:模块内 Tabs 顶部固定模式
|
|
||||||
|
|
||||||
模块根容器用 `h-full overflow-hidden flex flex-col`,Tabs 用 `flex-1 min-h-0 flex flex-col`,TabsList 加 `shrink-0`,TabsContent 加 `flex-1 min-h-0 overflow-y-auto`:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<div class="h-full p-6 overflow-hidden flex flex-col">
|
|
||||||
<Tabs v-model="tab" class="flex-1 min-h-0 flex flex-col">
|
|
||||||
<TabsList class="shrink-0">...</TabsList>
|
|
||||||
<TabsContent value="x" class="flex-1 min-h-0 overflow-y-auto">...</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
这样 TabsList 固定在顶部,只有 TabsContent 滚动。`min-h-0` 是 flex 子元素 overflow 生效的关键,不能省。
|
|
||||||
|
|
||||||
### 前端:Tauri 事件监听需在 store 中管理生命周期
|
|
||||||
|
|
||||||
`proxyStore.ts` 的 `installKernel` 确立了模式:
|
|
||||||
|
|
||||||
- 监听在方法调用时注册,`finally` 块中取消
|
|
||||||
- 用模块级变量保存 `UnlistenFn`,避免重复注册
|
|
||||||
- 错误事件由后端保证 emit(前端不重复弹 toast,统一由 watch 处理)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let progressUnlisten: UnlistenFn | null = null
|
|
||||||
const installKernel = async () => {
|
|
||||||
if (!progressUnlisten) {
|
|
||||||
progressUnlisten = await listen<Progress>('xxx-progress', (e) => {
|
|
||||||
progress.value = e.payload
|
|
||||||
})
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await invoke('xxx_command')
|
|
||||||
} finally {
|
|
||||||
if (progressUnlisten) {
|
|
||||||
progressUnlisten()
|
|
||||||
progressUnlisten = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端:窗口隐藏前 blur 焦点
|
|
||||||
|
|
||||||
TitleBar 的关闭按钮实际是 `hide()` 到托盘。webview 快速隐藏时浏览器 `mouseleave` 可能不触发,导致从托盘恢复后按钮仍显示 hover 高亮。
|
|
||||||
|
|
||||||
**修复**:`hide()` 前调用 `document.activeElement.blur()`,并监听 `onFocusChanged` 在窗口重新获得焦点时再 blur 一次。新模块若有类似的"隐藏窗口"操作(如全局快捷键隐藏),同样需要 blur。
|
|
||||||
|
|
||||||
### 前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示)
|
|
||||||
|
|
||||||
模块详情页内容滚动时,顶部 `TabsList` 会被 `TitleBar` 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable `src/lib/use-module-tabs.ts` 自动处理。
|
|
||||||
|
|
||||||
**接入方式**(任何使用 Tabs 的模块都可用,代理/下载器模块已接入):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 模块 <script setup> 顶部
|
|
||||||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
|
||||||
|
|
||||||
const activeTab = ref('overview')
|
|
||||||
const tabsListRef = useModuleTabs(activeTab, [
|
|
||||||
{ value: 'overview', label: '概览' },
|
|
||||||
{ value: 'settings', label: '设置' }
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- 模板中给 TabsList 包一层带 ref 的 div -->
|
|
||||||
<Tabs v-model="activeTab">
|
|
||||||
<div ref="tabsListRef">
|
|
||||||
<TabsList>...</TabsList>
|
|
||||||
</div>
|
|
||||||
<TabsContent .../>
|
|
||||||
</Tabs>
|
|
||||||
```
|
|
||||||
|
|
||||||
**工作原理**:
|
|
||||||
- composable 内部在 `onMounted` 时注册标签到 `moduleTabsStore`,`onUnmounted` 时注销
|
|
||||||
- 用 `IntersectionObserver`(`rootMargin: '-44px 0px 0px 0px'` 裁剪 TitleBar 高度)监听 TabsList 可见性
|
|
||||||
- 滚动遮挡时 `TitleBar` 中"Thing"标题右侧自动显示浮动切换按钮(带淡入+左滑动画)
|
|
||||||
- 双向 watch 同步本地 `activeTab` 与 store,用户点击 TitleBar 浮动按钮也能切换模块内 Tab
|
|
||||||
|
|
||||||
**约束**:TitleBar 高度固定 40px(h-10),composable 已用 44px 裁剪(含 4px 缓冲);store 是单例,一个模块同一时间只能注册一组标签。
|
|
||||||
|
|
||||||
### 前端:内核路径用 %APPDATA% 简化显示
|
|
||||||
|
|
||||||
内核路径较长(如 `C:\Users\xxx\AppData\Roaming\thing.lfeng.me\proxy\cores\mihomo.exe`),展示时用 `%APPDATA%` 替换前缀,并提供复制完整路径 / 在文件夹中显示两个按钮。代理和下载器模块的内核管理区块已采用此模式。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { appDataDir } from '@tauri-apps/api/path'
|
|
||||||
|
|
||||||
const appDataPath = ref('')
|
|
||||||
onMounted(async () => {
|
|
||||||
try { appDataPath.value = await appDataDir() } catch {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const pathDisplay = computed(() => {
|
|
||||||
const p = store.kernel?.path
|
|
||||||
if (!p) return ''
|
|
||||||
if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) {
|
|
||||||
return '%APPDATA%' + p.slice(appDataPath.value.length)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`<title>` 放完整路径,显示文本用 `pathDisplay`,复制时复制完整路径。
|
|
||||||
|
|
||||||
### 前端:任务历史持久化(下载器模块模式)
|
|
||||||
|
|
||||||
下载引擎在进程内运行,状态通过 `engine_state.json` 持久化到磁盘。前端 store 的做法:
|
|
||||||
|
|
||||||
- `onMounted` 时调用 `store.init()`,一次性加载 status/settings/extensionInfo/tasks
|
|
||||||
- 通过 Tauri 事件 `download-progress`、`download-complete`、`download-added` 实时更新任务状态
|
|
||||||
- 引擎在 `Storage::new` 时自动创建数据目录,`save` 时兜底重建父目录,避免路径不存在错误
|
|
||||||
|
|
||||||
此模式适用于任何"引擎在进程内运行、状态需持久化"的场景。
|
|
||||||
|
|
||||||
### 前端:轻量设置 Dialog(替代独立 Tab)
|
|
||||||
|
|
||||||
模块设置项较多时,传统做法是单独开一个"设置"Tab。但任务页工具栏需要快速调整少量核心设置(如下载目录、并发数),切到设置 Tab 再切回来体验割裂。
|
|
||||||
|
|
||||||
**模式**:在任务工具栏放一个"下载设置"按钮,点击弹出 Dialog,包含核心设置项(与设置页共用 `store.settings`),保存时调用 `handleSaveSettings`(引擎立即应用新配置,无需重启)并自动关闭弹窗。完整设置仍保留在"设置"Tab。
|
|
||||||
|
|
||||||
适用场景:需要在任务页快速调整的少量高频设置;若设置项不多,可完全用 Dialog 替代设置 Tab。
|
|
||||||
|
|
||||||
### 后端:opener 插件 scope 限制与绕过
|
|
||||||
|
|
||||||
`tauri-plugin-opener` 的 `opener:allow-open-path` 权限默认 **无 scope**,IPC `open_path` 命令会拒绝任何路径并报错 `Not allowed to open path`。
|
|
||||||
|
|
||||||
**解决方案**:在 Rust 端写一个自定义命令,用 `OpenerExt::opener().open_path()` 直接调用(绕过 IPC scope 检查):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn my_open_dir(app: AppHandle, path: String) -> Result<(), String> {
|
|
||||||
let p = std::path::Path::new(&path);
|
|
||||||
if !p.exists() {
|
|
||||||
return Err(format!("路径不存在: {}", path));
|
|
||||||
}
|
|
||||||
app.opener().open_path(path, None::<&str>).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `lib.rs` 的 `invoke_handler` 中注册,前端通过 `invoke('my_open_dir', { path })` 调用。下载器模块的"打开下载目录"已采用此方案。
|
|
||||||
|
|
||||||
### 前端:剩余时间(ETA)可读化
|
|
||||||
|
|
||||||
下载卡片直接显示 `(total-completed)/speed` 会得到 `17.217666215634114 B` 这种难懂的字节数。应格式化为时间:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const formatEta = (seconds: number): string => {
|
|
||||||
if (!isFinite(seconds) || seconds <= 0) return ''
|
|
||||||
if (seconds < 60) return `剩余 ${Math.ceil(seconds)} 秒`
|
|
||||||
if (seconds < 3600) {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = Math.ceil(seconds % 60)
|
|
||||||
return `剩余 ${m} 分 ${s} 秒`
|
|
||||||
}
|
|
||||||
const h = Math.floor(seconds / 3600)
|
|
||||||
const m = Math.ceil((seconds % 3600) / 60)
|
|
||||||
return `剩余 ${h} 小时 ${m} 分`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`getEta(task) = (totalLength - completedLength) / downloadSpeed`,speed≤0 时返回 0(不显示)。
|
|
||||||
|
|
||||||
### 前端:状态统计用 Badge 替代纯文本
|
|
||||||
|
|
||||||
下载任务工具栏的速度/活跃/等待/已完成数量,以及代理概览页的内核状态/版本,从纯文本改为 `Badge variant="secondary"` 或带颜色的 `Badge`(绿色已安装/红色未安装),视觉更醒目。新模块的状态展示建议统一用 Badge。
|
|
||||||
|
|
||||||
### 后端:应用退出清理(单一出口)
|
|
||||||
|
|
||||||
退出清理已收敛到 `lib.rs` 的 `RunEvent::ExitRequested` 分支(P0-1/V7):`quit_app` 命令与托盘退出项都只触发 `app.exit(0)`,清理逻辑(`cleanup_on_exit` + `stop_all`)只在该分支执行一次。新增管理子进程的模块时,在 `ExitRequested` 分支注册自己的 `cleanup_on_exit` 即可,不要在命令或托盘路径重复清理。
|
|
||||||
@@ -100,7 +100,7 @@ Thing/
|
|||||||
#### 📸 截图
|
#### 📸 截图
|
||||||
- [x] 截图及相关
|
- [x] 截图及相关
|
||||||
- [ ] 滚动截图(后续支持)
|
- [ ] 滚动截图(后续支持)
|
||||||
- [ ] 贴图(后续支持)
|
- [x] 贴图
|
||||||
|
|
||||||
#### 📊 硬件监控
|
#### 📊 硬件监控
|
||||||
- [x] 悬浮窗显示
|
- [x] 悬浮窗显示
|
||||||
@@ -111,7 +111,7 @@ Thing/
|
|||||||
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
- [x] 自建进程内下载引擎(多线程 HTTP/HTTPS,无需外部内核)
|
||||||
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
- [x] 接管浏览器下载,浏览器扩展(Thing Extension)
|
||||||
- [x] HTTP 下载支持
|
- [x] HTTP 下载支持
|
||||||
- [ ] BT/磁力链接支持(后续支持)
|
- [x] BT/磁力链接支持(后续支持)
|
||||||
- [x] 下载任务管理(历史)
|
- [x] 下载任务管理(历史)
|
||||||
- [x] 速度限制
|
- [x] 速度限制
|
||||||
- [x] 断点续传
|
- [x] 断点续传
|
||||||
@@ -124,11 +124,11 @@ Thing/
|
|||||||
|
|
||||||
### 第三阶段:优化与完善
|
### 第三阶段:优化与完善
|
||||||
|
|
||||||
- [x] 性能优化(P1/P2:轮询随窗口可见性暂停、批量测速限并发、渲染 memo 化等,见 MODULE_REVIEW.md)
|
- [x] 性能优化(见 MODULE_REVIEW.md)
|
||||||
- [x] 错误处理与日志完善(B5 进程级全局日志器、异常兜底)
|
- [x] 错误处理与日志完善(全局日志器、异常兜底)
|
||||||
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
- [x] 用户体验优化(混合 DPI 定位、rAF 节流、UI 细节)
|
||||||
- [ ] 自动更新机制
|
- [x] 自动更新机制
|
||||||
- [ ] 打包发布
|
- [x] 打包发布
|
||||||
|
|
||||||
## 模块管理架构
|
## 模块管理架构
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ internal sealed class KernelStatus
|
|||||||
{
|
{
|
||||||
public bool Ready { get; set; }
|
public bool Ready { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
/// <summary>PawnIO 驱动是否已安装(ring0 传感器读取依赖它或 WinRing0,缺失时温度/频率通常无法读取)</summary>
|
||||||
|
public bool PawnIoInstalled { get; set; }
|
||||||
public double UptimeMs { get; set; }
|
public double UptimeMs { get; set; }
|
||||||
public int GroupCount { get; set; }
|
public int GroupCount { get; set; }
|
||||||
public int SensorCount { get; set; }
|
public int SensorCount { get; set; }
|
||||||
|
|||||||
+45
-11
@@ -24,6 +24,9 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
private readonly bool _isAdmin;
|
private readonly bool _isAdmin;
|
||||||
private readonly double _coldStartMs;
|
private readonly double _coldStartMs;
|
||||||
private readonly Stopwatch _startupSw;
|
private readonly Stopwatch _startupSw;
|
||||||
|
// 传感器 ID 缓存:避免每秒为每个传感器重复拼接字符串(减少 GC 压力)
|
||||||
|
private readonly Dictionary<ISensor, string> _sensorIdCache = new();
|
||||||
|
private bool _coldStartSent;
|
||||||
private bool _ready;
|
private bool _ready;
|
||||||
private bool _closed;
|
private bool _closed;
|
||||||
private HardwareConfig _config;
|
private HardwareConfig _config;
|
||||||
@@ -84,7 +87,7 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 全量 Update 所有硬件。
|
/// 全量 Update 所有硬件。
|
||||||
/// 由 SamplingScheduler 按通道分频调用。
|
/// 仅用于构造函数首轮填充,运行期由调度器分频调用 UpdateFastOnly/UpdateSlowOnly。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateAll()
|
public void UpdateAll()
|
||||||
{
|
{
|
||||||
@@ -94,9 +97,24 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 仅 Update 快通道硬件(CPU/GPU/Memory/Network 等)。
|
||||||
|
/// 由调度器快通道 tick 高频调用,避免 SMART 等重查询拖慢采样节奏。
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateFastOnly()
|
||||||
|
{
|
||||||
|
foreach (var hw in _visitor.AllHardware)
|
||||||
|
{
|
||||||
|
if (!IsSlowHardware(hw.HardwareType))
|
||||||
|
{
|
||||||
|
try { hw.Update(); } catch { /* 单个硬件 Update 失败不影响整体 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。
|
/// 仅 Update 慢通道硬件(Storage/PSU/Battery 等)。
|
||||||
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateAll。
|
/// 快通道硬件(CPU/GPU/Memory/Network)由调度器更高频调用 UpdateFastOnly。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateSlowOnly()
|
public void UpdateSlowOnly()
|
||||||
{
|
{
|
||||||
@@ -123,10 +141,11 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
Ready = _ready,
|
Ready = _ready,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 首个快照带上冷启动耗时,后续为 0
|
// 首个快照带上冷启动耗时,后续为 0(修复:此前每个快照都携带 ColdStartMs)
|
||||||
if (_coldStartMs > 0 && snap.Timestamp > 0)
|
if (_coldStartMs > 0 && !_coldStartSent)
|
||||||
{
|
{
|
||||||
snap.ColdStartMs = Math.Round(_coldStartMs, 1);
|
snap.ColdStartMs = Math.Round(_coldStartMs, 1);
|
||||||
|
_coldStartSent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时)
|
// 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时)
|
||||||
@@ -156,7 +175,7 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
|
|
||||||
g.Sensors.Add(new SensorEntry
|
g.Sensors.Add(new SensorEntry
|
||||||
{
|
{
|
||||||
Id = $"{groupId}/{hw.Name}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant(),
|
Id = GetSensorId(s, groupId, hw.Name),
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
Type = sensorType,
|
Type = sensorType,
|
||||||
Value = s.Value,
|
Value = s.Value,
|
||||||
@@ -169,6 +188,21 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
return snap;
|
return snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取传感器稳定 ID(带缓存)。
|
||||||
|
/// ID 格式:{groupId}/{hwName}/{sensorType}/{sensorName},空格转下划线、小写化。
|
||||||
|
/// 缓存命中直接返回,未命中(新传感器)计算后入缓存。
|
||||||
|
/// </summary>
|
||||||
|
private string GetSensorId(ISensor s, string groupId, string hwName)
|
||||||
|
{
|
||||||
|
if (!_sensorIdCache.TryGetValue(s, out var id))
|
||||||
|
{
|
||||||
|
id = $"{groupId}/{hwName}/{s.SensorType}/{s.Name}".Replace(' ', '_').ToLowerInvariant();
|
||||||
|
_sensorIdCache[s] = id;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 为已启用但 LHB 未枚举到的硬件类型预创建空分组。
|
/// 为已启用但 LHB 未枚举到的硬件类型预创建空分组。
|
||||||
/// 场景:用户在设置中勾选了主板/电池/电源等,但 LHB 在当前权限或机型下检测不到对应硬件,
|
/// 场景:用户在设置中勾选了主板/电池/电源等,但 LHB 在当前权限或机型下检测不到对应硬件,
|
||||||
@@ -243,7 +277,7 @@ internal sealed class HardwareManager : IDisposable
|
|||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static bool IsRunningAsAdmin()
|
internal static bool IsRunningAsAdmin()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -290,14 +324,14 @@ internal sealed class SnapshotVisitor : IVisitor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 采样调度器:按快/慢通道分频驱动 HardwareManager.UpdateAll。
|
/// 采样调度器:按快/慢通道分频驱动 HardwareManager 的分层 Update。
|
||||||
/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。
|
/// 使用 Channel 向 SSE 推送层广播快照(解耦:调度器不关心有几个订阅者)。
|
||||||
///
|
///
|
||||||
/// 调度策略:
|
/// 调度策略:
|
||||||
/// - 快通道 tick:UpdateAll(含慢通道硬件,因 UpdateAll 成本主要在 SMART,已通过慢通道分频减少调用频率)
|
/// - 快通道 tick:UpdateFastOnly(仅 CPU/GPU/Memory/Network 等轻量硬件),
|
||||||
/// 实际优化:快通道 tick 只 Update 快通道硬件(UpdateFastOnly),慢通道单独按慢节奏 Update
|
/// 避免 SMART 等重查询每秒执行拖慢采样节奏
|
||||||
/// - 慢通道 tick:UpdateSlowOnly(仅 Storage/PSU/Motherboard 等)
|
/// - 慢通道 tick:UpdateSlowOnly(仅 Storage/PSU/Motherboard 等)
|
||||||
/// - 每个 tick 结束后构建快照并广播
|
/// - 快通道每个 tick 结束后构建快照并广播(慢通道更新后的值随下一帧带出)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SamplingScheduler : IDisposable
|
internal sealed class SamplingScheduler : IDisposable
|
||||||
{
|
{
|
||||||
@@ -352,7 +386,7 @@ internal sealed class SamplingScheduler : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_hw.UpdateAll();
|
_hw.UpdateFastOnly();
|
||||||
var snap = _hw.BuildSnapshot();
|
var snap = _hw.BuildSnapshot();
|
||||||
_cache.Update(snap);
|
_cache.Update(snap);
|
||||||
_broadcast.Writer.TryWrite(snap);
|
_broadcast.Writer.TryWrite(snap);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ internal static class HttpEndpoints
|
|||||||
{
|
{
|
||||||
Ready = hw?.Ready ?? false,
|
Ready = hw?.Ready ?? false,
|
||||||
IsAdmin = hw?.IsAdmin ?? false,
|
IsAdmin = hw?.IsAdmin ?? false,
|
||||||
|
PawnIoInstalled = PawnIoSupport.IsServiceInstalled(),
|
||||||
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
UptimeMs = kernel.Uptime.Elapsed.TotalMilliseconds,
|
||||||
GroupCount = snap?.Groups.Count ?? 0,
|
GroupCount = snap?.Groups.Count ?? 0,
|
||||||
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
SensorCount = kernel.Scheduler.Cache.SensorCount,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace ThingHK;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PawnIO 驱动支持:检测 + 静默安装。
|
||||||
|
///
|
||||||
|
/// 背景:LHM 读取 CPU 温度/频率等 ring0 数据依赖内核驱动,回退用的 WinRing0 被
|
||||||
|
/// 微软"易受攻击的驱动程序阻止列表"和部分杀软(如火绒)拦截,导致传感器缺失。
|
||||||
|
/// PawnIO 是正规签名的替代驱动(不在阻止列表、兼容 HVCI/安全启动),
|
||||||
|
/// LHM 0.9.5+ 检测到已安装时优先使用,无需任何代码开关。
|
||||||
|
///
|
||||||
|
/// 安装器约定:PawnIO_setup.exe 与 ThingHK.exe 同目录
|
||||||
|
/// (由 Tauri 侧 prepare_kernel 从资源目录随内核一起复制到 {app_data}/monitor/cores/)。
|
||||||
|
///
|
||||||
|
/// 静默参数:-install -silent(官方 CLI 参数,见 namazso/PawnIO.Setup)。
|
||||||
|
/// 退出码:0=成功;3010=成功但需重启(ERROR_SUCCESS_REBOOT_REQUIRED)。
|
||||||
|
///
|
||||||
|
/// 策略:仅在内核已提权时安装。两种提权模式(Thing 提权继承 / 仅提权 ThingHK)
|
||||||
|
/// 都只有一次 UAC,内核拿到权限后自行静默安装,避免二次弹窗。
|
||||||
|
/// serve 模式调用;scan 诊断模式不安装,保持被动。
|
||||||
|
/// </summary>
|
||||||
|
internal static class PawnIoSupport
|
||||||
|
{
|
||||||
|
/// <summary>驱动服务注册表键:存在即认为已安装</summary>
|
||||||
|
private const string ServiceKeyName = @"SYSTEM\CurrentControlSet\Services\PawnIO";
|
||||||
|
|
||||||
|
private const string SetupFileName = "PawnIO_setup.exe";
|
||||||
|
|
||||||
|
/// <summary>3010 = ERROR_SUCCESS_REBOOT_REQUIRED(安装成功但需重启生效)</summary>
|
||||||
|
private const int ExitCodeRebootRequired = 3010;
|
||||||
|
|
||||||
|
/// <summary>驱动安装通常数秒内完成,留足余量防止卡死启动流程</summary>
|
||||||
|
private const int InstallTimeoutMs = 90_000;
|
||||||
|
|
||||||
|
/// <summary>检测 PawnIO 驱动服务是否已注册</summary>
|
||||||
|
public static bool IsServiceInstalled()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(ServiceKeyName);
|
||||||
|
return key != null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保 PawnIO 就绪:已安装直接返回;未安装且当前已提权时静默安装。
|
||||||
|
/// 返回描述性结果(写入 stderr 日志 + /status 诊断)。
|
||||||
|
/// </summary>
|
||||||
|
public static string EnsureInstalled()
|
||||||
|
{
|
||||||
|
if (IsServiceInstalled())
|
||||||
|
return "already-installed";
|
||||||
|
|
||||||
|
if (!HardwareManager.IsRunningAsAdmin())
|
||||||
|
return "skipped: not elevated (温度/频率等传感器需要提权运行)";
|
||||||
|
|
||||||
|
string setupPath = Path.Combine(AppContext.BaseDirectory, SetupFileName);
|
||||||
|
if (!File.Exists(setupPath))
|
||||||
|
return $"skipped: {SetupFileName} 未找到(应随内核一起部署,见 prepare_kernel)";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = setupPath,
|
||||||
|
Arguments = "-install -silent",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
});
|
||||||
|
if (process == null)
|
||||||
|
return "failed: Process.Start 返回 null";
|
||||||
|
|
||||||
|
if (!process.WaitForExit(InstallTimeoutMs))
|
||||||
|
{
|
||||||
|
try { process.Kill(); } catch { /* 超时后进程可能已自行退出 */ }
|
||||||
|
return "failed: 安装超时";
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = process.ExitCode;
|
||||||
|
if (code == ExitCodeRebootRequired)
|
||||||
|
return "installed: 需重启后生效";
|
||||||
|
|
||||||
|
if (code != 0)
|
||||||
|
return $"failed: 安装器退出码 {code}";
|
||||||
|
|
||||||
|
return IsServiceInstalled()
|
||||||
|
? "installed"
|
||||||
|
: "failed: 安装器返回 0 但服务未注册";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,11 @@ internal static class Program
|
|||||||
|
|
||||||
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
Console.Error.WriteLine($"[ThingHK] serve 模式: port={port} config={configPath ?? "(默认)"} fast={fastMs}ms slow={slowMs}ms");
|
||||||
|
|
||||||
|
// PawnIO:ring0 传感器读取的首选驱动(未安装且已提权时静默安装,
|
||||||
|
// 避开 WinRing0 被系统阻止列表/杀软拦截导致的温度/频率缺失)
|
||||||
|
string pawnIoResult = PawnIoSupport.EnsureInstalled();
|
||||||
|
Console.Error.WriteLine($"[ThingHK] PawnIO: {pawnIoResult}");
|
||||||
|
|
||||||
using var kernel = new KernelHost();
|
using var kernel = new KernelHost();
|
||||||
await kernel.StartAsync(configPath, fastMs, slowMs);
|
await kernel.StartAsync(configPath, fastMs, slowMs);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre716" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre729" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pinyin-pro": "^3.28.2",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
@@ -381,6 +382,8 @@
|
|||||||
|
|
||||||
"pinia": ["pinia@3.0.4", "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
"pinia": ["pinia@3.0.4", "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
|
||||||
|
|
||||||
|
"pinyin-pro": ["pinyin-pro@3.29.4", "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.4.tgz", {}, "sha512-SPXpDT2cHEy+d26V1RXYMlVzXN42hotFAak1fzyWPi4o2dKXb61UqD4pzxDJHwk6gbv8vQ6EfErd+hYX0Qhzug=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.19", "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
"postcss": ["postcss@8.5.19", "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
||||||
|
|
||||||
"reka-ui": ["reka-ui@2.10.1", "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg=="],
|
"reka-ui": ["reka-ui@2.10.1", "https://registry.npmmirror.com/reka-ui/-/reka-ui-2.10.1.tgz", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-drcOQ4rQtDYAcGCsyQBqQg8QQ+H3B+zDaMJU0h8KPEPMa7g9BHu3zcOi4OB39XJSWizceFoNO0Z9tctSGLOXqg=="],
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "thing",
|
"name": "thing",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "26.9.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
set "SCRIPT=%~dp0release.ps1"
|
||||||
|
|
||||||
|
echo ================================================
|
||||||
|
echo Thing Build Pubilsh
|
||||||
|
echo 发布目标:https://gitea.atie.fun/LFeng/Thing
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ---------- 版本号 ----------
|
||||||
|
set "VERSION="
|
||||||
|
set /p "VERSION=请输入版本号(如 26.8.1,直接回车沿用当前版本): "
|
||||||
|
set "VER_ARG="
|
||||||
|
if not "%VERSION%"=="" set "VER_ARG=-Version %VERSION%"
|
||||||
|
|
||||||
|
:: ---------- 发布模式 ----------
|
||||||
|
echo.
|
||||||
|
echo [1] Build + Pubilsh Gitea
|
||||||
|
echo [2] Build Only
|
||||||
|
echo [3] Pubilsh Gitea
|
||||||
|
set "MODE="
|
||||||
|
set /p "MODE=请输入数字选择(回车默认 1): "
|
||||||
|
if "%MODE%"=="" set "MODE=1"
|
||||||
|
|
||||||
|
set "EXTRA="
|
||||||
|
if "%MODE%"=="2" (
|
||||||
|
set "EXTRA=-SkipPush"
|
||||||
|
) else if "%MODE%"=="3" (
|
||||||
|
set "EXTRA=-SkipBuild"
|
||||||
|
)
|
||||||
|
|
||||||
|
:: ---------- Gitea Token(仅完整发布模式需要) ----------
|
||||||
|
if "%MODE%"=="1" if not defined GITEA_TOKEN (
|
||||||
|
echo.
|
||||||
|
echo 未检测到环境变量 GITEA_TOKEN,上传到 Gitea 需要它。
|
||||||
|
echo 可在此临时输入(仅本次会话生效),留空则自动改为"只构建不上传"。
|
||||||
|
set /p "GITEA_TOKEN=请输入 Gitea Token: "
|
||||||
|
if "!GITEA_TOKEN!"=="" (
|
||||||
|
set "EXTRA=-SkipPush"
|
||||||
|
set "MODE=2"
|
||||||
|
echo [提示] 已切换为"只构建并整理产物,不上传"。
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Start:release.ps1 %VER_ARG% %EXTRA%
|
||||||
|
echo ------------------------------------------------
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%" %VER_ARG% %EXTRA%
|
||||||
|
set "RESULT=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if "%RESULT%"=="0" (
|
||||||
|
echo 执行完成。
|
||||||
|
) else (
|
||||||
|
echo 执行出错,请查看上方日志。
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Thing 构建发布脚本(目标:自建 Gitea release)
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# .\scripts\release.ps1 -Version 0.2.0 # 同步版本号 + 构建 + 发布
|
||||||
|
# .\scripts\release.ps1 -Version 0.2.0 -SkipBuild # 复用现有构建产物,直接发布
|
||||||
|
# .\scripts\release.ps1 -Version 0.2.0 -SkipPush # 只构建+整理产物,不上传
|
||||||
|
#
|
||||||
|
# 发布产物(上传到 https://gitea.atie.fun/LFeng/Thing 的 v{Version} release):
|
||||||
|
# thing_{v}_x64.exe 便携免安装版(无内核)
|
||||||
|
# thing_{v}_x64.msi 安装版 MSI(去掉 tauri 默认的 _en-US 后缀)
|
||||||
|
# thing_{v}_x64-setup.exe 安装版 NSIS
|
||||||
|
# thing-hk_{v}.zip ThingHK 硬件监控内核(mihomo 继续走代理模块内置的 GitHub 下载)
|
||||||
|
#
|
||||||
|
# 前提:
|
||||||
|
# - Gitea token 已配置为环境变量 GITEA_TOKEN(-SkipPush 时不需要)
|
||||||
|
# - 可选 -NotesPath 指定 release notes 文件(Markdown 文本),
|
||||||
|
# 缺省时使用 scripts/release-notes.md(若存在),否则用简单占位文本
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Version = '',
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[switch]$SkipPush,
|
||||||
|
[string]$NotesPath = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$TargetDir = Join-Path $Root 'src-tauri\target\release'
|
||||||
|
$BundleDir = Join-Path $TargetDir 'bundle'
|
||||||
|
$RepoOwner = 'LFeng'
|
||||||
|
$RepoName = 'Thing'
|
||||||
|
$ApiBase = 'https://gitea.atie.fun/api/v1'
|
||||||
|
$Product = 'thing'
|
||||||
|
|
||||||
|
function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||||
|
|
||||||
|
# ---------- 0. 版本号 ----------
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
$conf = Get-Content (Join-Path $Root 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json
|
||||||
|
$Version = $conf.version
|
||||||
|
Write-Step "未指定 -Version,沿用现有版本 $Version"
|
||||||
|
}
|
||||||
|
# 校验 X.Y.Z 格式
|
||||||
|
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
|
||||||
|
throw "版本号格式错误(应为 X.Y.Z):$Version"
|
||||||
|
}
|
||||||
|
Write-Step "发布版本:v$Version"
|
||||||
|
|
||||||
|
# ---------- 1. 同步版本号到三处 ----------
|
||||||
|
$tauriConf = Join-Path $Root 'src-tauri\tauri.conf.json'
|
||||||
|
$cargoToml = Join-Path $Root 'src-tauri\Cargo.toml'
|
||||||
|
$pkgJson = Join-Path $Root 'package.json'
|
||||||
|
|
||||||
|
# UTF-8 安全读写:`Get-Content` 默认按系统 ANSI(如 GBK) 解码,会把本就含中文/乱码的文件二次编码损坏(曾导致
|
||||||
|
# Cargo.toml 的 keyring 依赖行被弄丢)。这里统一用显式 UTF-8 无 BOM 读写,保证逐字节稳定往返。
|
||||||
|
function Set-Utf8Version([string]$Path, [string]$Pattern, [string]$NewVersion) {
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$content = [System.IO.File]::ReadAllText($Path, $utf8NoBom)
|
||||||
|
$content = $content -replace $Pattern, "`${1}$NewVersion`${2}"
|
||||||
|
[System.IO.File]::WriteAllText($Path, $content, $utf8NoBom)
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Utf8Version -Path $tauriConf -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version
|
||||||
|
Set-Utf8Version -Path $cargoToml -Pattern '(?m)^(version\s*=\s*")[^"]*(")' -NewVersion $Version
|
||||||
|
Set-Utf8Version -Path $pkgJson -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version
|
||||||
|
Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json"
|
||||||
|
|
||||||
|
# ---------- 2. 构建 ----------
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Step '开始构建(bun run tauri build)...'
|
||||||
|
Push-Location $Root
|
||||||
|
try { bun run tauri build }
|
||||||
|
finally { Pop-Location }
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'tauri build 失败' }
|
||||||
|
} else {
|
||||||
|
Write-Step '跳过构建,复用现有产物'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 3. 整理产物 ----------
|
||||||
|
# 版本号此时已解析,再确定暂存目录
|
||||||
|
$StageDir = Join-Path $Root "release_stage\$Version"
|
||||||
|
New-Item -ItemType Directory -Force -Path $StageDir | Out-Null
|
||||||
|
|
||||||
|
$exeSrc = Join-Path $TargetDir "$Product.exe"
|
||||||
|
$msiSrc = Join-Path $BundleDir "msi\${Product}_${Version}_x64_en-US.msi"
|
||||||
|
$nsisSrc = Join-Path $BundleDir "nsis\${Product}_${Version}_x64-setup.exe"
|
||||||
|
$hkSrc = Join-Path $Root 'src-tauri\binaries\ThingHK.exe'
|
||||||
|
|
||||||
|
$exeOut = Join-Path $StageDir "${Product}_${Version}_x64.exe"
|
||||||
|
$msiOut = Join-Path $StageDir "${Product}_${Version}_x64.msi"
|
||||||
|
$nsisOut = Join-Path $StageDir "${Product}_${Version}_x64-setup.exe"
|
||||||
|
$hkZip = Join-Path $StageDir "thing-hk_${Version}.zip"
|
||||||
|
|
||||||
|
if (Test-Path $exeSrc) { Copy-Item $exeSrc $exeOut } else { Write-Warning "缺少便携版:$exeSrc" }
|
||||||
|
if (Test-Path $msiSrc) { Copy-Item $msiSrc $msiOut } else { Write-Warning "缺少 MSI(已跳过重命名):$msiSrc" }
|
||||||
|
if (Test-Path $nsisSrc) { Copy-Item $nsisSrc $nsisOut } else { Write-Warning "缺少 NSIS:$nsisSrc" }
|
||||||
|
|
||||||
|
if (Test-Path $hkSrc) {
|
||||||
|
$tmp = Join-Path $env:TEMP "thinghk_$([guid]::NewGuid().ToString('N'))"
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||||
|
Copy-Item $hkSrc (Join-Path $tmp 'ThingHK.exe')
|
||||||
|
Compress-Archive -Path (Join-Path $tmp 'ThingHK.exe') -DestinationPath $hkZip -Force
|
||||||
|
Remove-Item -Recurse -Force $tmp
|
||||||
|
Write-Step "ThingHK 内核包:$hkZip"
|
||||||
|
} else {
|
||||||
|
Write-Warning "缺少 ThingHK.exe:$hkSrc"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "产物已整理到:$StageDir"
|
||||||
|
Get-ChildItem $StageDir | Select-Object Name, @{n='Size(MB)';e={[math]::Round($_.Length/1MB,1)}} | Format-Table -AutoSize
|
||||||
|
|
||||||
|
# ---------- 4. 上传到 Gitea ----------
|
||||||
|
if ($SkipPush) {
|
||||||
|
Write-Step '已跳过上传(-SkipPush)'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $env:GITEA_TOKEN
|
||||||
|
if ([string]::IsNullOrWhiteSpace($token)) {
|
||||||
|
throw '未设置环境变量 GITEA_TOKEN,无法发布到 Gitea(或使用 -SkipPush 跳过上传)'
|
||||||
|
}
|
||||||
|
$auth = @{ Authorization = "token $token" }
|
||||||
|
|
||||||
|
# 4.1 release notes
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($NotesPath)) {
|
||||||
|
$body = Get-Content $NotesPath -Raw
|
||||||
|
} elseif (Test-Path (Join-Path $PSScriptRoot 'release-notes.md')) {
|
||||||
|
$body = Get-Content (Join-Path $PSScriptRoot 'release-notes.md') -Raw
|
||||||
|
} else {
|
||||||
|
$body = "Thing v$Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4.2 创建 release(已存在同名 tag 则复用)
|
||||||
|
Write-Step "创建 release v$Version ..."
|
||||||
|
$releaseUrl = "$ApiBase/repos/$RepoOwner/$RepoName/releases"
|
||||||
|
$releasePayload = @{
|
||||||
|
tag_name = "v$Version"
|
||||||
|
name = "Thing v$Version"
|
||||||
|
body = $body
|
||||||
|
draft = $false
|
||||||
|
prerelease = $false
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Method Post -Uri $releaseUrl -Headers $auth -ContentType 'application/json' -Body $releasePayload
|
||||||
|
} catch {
|
||||||
|
# tag 已存在:尝试用该 tag 查找现有 release,后续资产上传会追加
|
||||||
|
Write-Warning "创建 release 失败,尝试复用已有 release:$_"
|
||||||
|
$release = Invoke-RestMethod -Method Get -Uri "$releaseUrl/tags/v$Version" -Headers $auth
|
||||||
|
}
|
||||||
|
$releaseId = $release.id
|
||||||
|
Write-Step "release id=$releaseId"
|
||||||
|
|
||||||
|
# 4.3 逐个上传资产
|
||||||
|
$assets = @($exeOut, $msiOut, $nsisOut, $hkZip) | Where-Object { Test-Path $_ }
|
||||||
|
foreach ($file in $assets) {
|
||||||
|
$name = Split-Path $file -Leaf
|
||||||
|
Write-Step "上传 $name ..."
|
||||||
|
$assetUrl = "$releaseUrl/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
$resp = Invoke-RestMethod -Method Post -Uri $assetUrl -Headers $auth -ContentType 'application/octet-stream' -InFile $file
|
||||||
|
Write-Host " -> $($resp.browser_download_url)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "发布完成:https://gitea.atie.fun/$RepoOwner/$RepoName/releases/tag/v$Version"
|
||||||
Generated
+1671
-25
File diff suppressed because it is too large
Load Diff
+62
-2
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "clipboard-preview",
|
||||||
|
"description": "Capability for clipboard independent preview window",
|
||||||
|
"windows": ["clipboard-preview"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-effects",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"snap-layout:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "download-window",
|
||||||
|
"description": "Capability for the per-download one-time window",
|
||||||
|
"windows": ["download-window-*"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
|
"core:window:allow-set-title",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-set-skip-taskbar",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-effects",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"snap-layout:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "screenshot",
|
"identifier": "screenshot",
|
||||||
"description": "Capability for screenshot overlay and editor windows",
|
"description": "Capability for screenshot overlay, editor, pin and scroll-control windows",
|
||||||
"windows": ["screenshot-overlay*", "screenshot-editor-*"],
|
"windows": ["screenshot-overlay*", "screenshot-editor*", "screenshot-pin", "screenshot-scroll-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
@@ -11,12 +11,14 @@
|
|||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-position",
|
"core:window:allow-set-position",
|
||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-resizable",
|
||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:allow-set-skip-taskbar",
|
"core:window:allow-set-skip-taskbar",
|
||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:event:allow-emit",
|
"core:event:allow-emit",
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"snap-layout:default"
|
"snap-layout:default"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "translate-popup",
|
||||||
|
"description": "Capability for the non-activating translate popup window",
|
||||||
|
"windows": ["translate-popup"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-effects",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"snap-layout:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ const DEFAULT_CONFIG = {
|
|||||||
interceptDownload: true,
|
interceptDownload: true,
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
excludeDomains: [],
|
excludeDomains: [],
|
||||||
showNotifications: true,
|
|
||||||
// 嗅探开关
|
// 嗅探开关
|
||||||
sniffEnabled: true,
|
sniffEnabled: true,
|
||||||
// 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包
|
// 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包
|
||||||
@@ -359,6 +358,11 @@ async function addDownload(url, filename, referer, cookies, headers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 下载拦截 =====
|
// ===== 下载拦截 =====
|
||||||
|
// 处理中的 URL(防止同一 URL 并发/重入,也避免与引擎去重检查竞态)
|
||||||
|
const processingUrls = new Set()
|
||||||
|
// 我们自己用 chrome.downloads.download 回退创建的下载 URL(短时间内跳过,防止再次被拦截形成死循环)
|
||||||
|
const fallbackUrls = new Map() // url -> 过期时间戳
|
||||||
|
|
||||||
async function shouldIntercept(downloadItem) {
|
async function shouldIntercept(downloadItem) {
|
||||||
const config = await getConfig()
|
const config = await getConfig()
|
||||||
if (!config.interceptDownload) return false
|
if (!config.interceptDownload) return false
|
||||||
@@ -374,42 +378,94 @@ async function shouldIntercept(downloadItem) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadCreated(downloadItem) {
|
/**
|
||||||
if (!await shouldIntercept(downloadItem)) return
|
* 判断是否为"历史下载"(安装插件之前就已存在、随后被浏览器恢复的旧下载)
|
||||||
|
* 这类下载一律不接管,交给浏览器原生处理,实现"只接管以后的下载,历史都不管"
|
||||||
|
*/
|
||||||
|
async function isHistoricalDownload(item) {
|
||||||
|
// 1) canResume=true 表示已存在有效的部分文件,说明浏览器在恢复旧下载
|
||||||
|
if (item.canResume) return true
|
||||||
|
// 2) 开始时间早于插件首次安装时间(浏览器重启后恢复的旧下载会保留原来的开始时间)
|
||||||
try {
|
try {
|
||||||
await chrome.downloads.cancel(downloadItem.id)
|
const start = item.startTime ? new Date(item.startTime).getTime() : 0
|
||||||
await chrome.downloads.erase({ id: downloadItem.id })
|
if (start > 0) {
|
||||||
|
const stored = await chrome.storage.local.get('installTime')
|
||||||
|
const installTime = stored.installTime || 0
|
||||||
|
if (installTime > 0 && start < installTime) return true
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const url = downloadItem.finalUrl || downloadItem.url
|
/**
|
||||||
const filename = downloadItem.filename || ''
|
* 查询引擎是否已有同 URL 的非终态任务(活跃/排队/暂停)
|
||||||
|
* 用于防止同一下载被重复转发、重复下载
|
||||||
|
*/
|
||||||
|
async function hasExistingTask(url) {
|
||||||
try {
|
try {
|
||||||
const id = await addDownload(url, filename, downloadItem.referrer, '')
|
const tasks = await apiRequest('/api/downloads')
|
||||||
await notify('已添加到 Thing', `${filename || url}`)
|
return tasks.some(t => {
|
||||||
} catch (e) {
|
const s = t.status
|
||||||
await notify('Thing 添加失败', `${filename || url}\n${e.message}`)
|
if (s === 'complete' || s === 'error') return false
|
||||||
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
|
return t.url === url
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 通知 =====
|
async function handleDownloadCreated(downloadItem) {
|
||||||
async function notify(title, message) {
|
const url = downloadItem.finalUrl || downloadItem.url
|
||||||
const config = await getConfig()
|
|
||||||
if (!config.showNotifications) return
|
// 回退下载:我们自己用 chrome.downloads.download 创建的,直接跳过,避免无限循环
|
||||||
|
const fbExp = fallbackUrls.get(url)
|
||||||
|
if (fbExp && Date.now() < fbExp) {
|
||||||
|
fallbackUrls.delete(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史下载(安装前的旧下载被浏览器恢复)一律不接管
|
||||||
|
if (await isHistoricalDownload(downloadItem)) return
|
||||||
|
|
||||||
|
if (!await shouldIntercept(downloadItem)) return
|
||||||
|
|
||||||
|
// 同一 URL 已在处理中,跳过(防并发/防重复转发)
|
||||||
|
if (processingUrls.has(url)) return
|
||||||
|
|
||||||
|
// 引擎不可达时不接管(保留浏览器原生下载),避免取消后下载无处可去
|
||||||
|
let connected = false
|
||||||
|
try { connected = await testConnection() } catch { connected = false }
|
||||||
|
if (!connected) return
|
||||||
|
|
||||||
|
// 引擎已有同 URL 的非终态任务,不重复转发
|
||||||
|
if (await hasExistingTask(url)) return
|
||||||
|
|
||||||
|
processingUrls.add(url)
|
||||||
try {
|
try {
|
||||||
await chrome.notifications.create({
|
try {
|
||||||
type: 'basic',
|
await chrome.downloads.cancel(downloadItem.id)
|
||||||
iconUrl: 'icons/icon-128.png',
|
await chrome.downloads.erase({ id: downloadItem.id })
|
||||||
title,
|
} catch { /* ignore */ }
|
||||||
message
|
|
||||||
})
|
const filename = downloadItem.filename || ''
|
||||||
} catch { /* ignore */ }
|
try {
|
||||||
|
await addDownload(url, filename, downloadItem.referrer, '')
|
||||||
|
} catch (e) {
|
||||||
|
// 添加失败:回退为浏览器自带下载,并标记该 URL 短时间内跳过,防止再次被拦截形成死循环
|
||||||
|
fallbackUrls.set(url, Date.now() + 3000)
|
||||||
|
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processingUrls.delete(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 右键菜单 =====
|
// ===== 右键菜单 & 安装标记 =====
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||||
|
// 记录首次安装时间:用于区分"安装前的历史下载"(被浏览器恢复的旧下载)与"安装后的新下载"
|
||||||
|
if (details.reason === 'install') {
|
||||||
|
await chrome.storage.local.set({ installTime: Date.now() })
|
||||||
|
}
|
||||||
chrome.contextMenus.create({
|
chrome.contextMenus.create({
|
||||||
id: 'thing-download-link',
|
id: 'thing-download-link',
|
||||||
title: '使用 Thing 下载此链接',
|
title: '使用 Thing 下载此链接',
|
||||||
@@ -428,10 +484,7 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|||||||
const filename = url.split('/').pop()?.split('?')[0] || ''
|
const filename = url.split('/').pop()?.split('?')[0] || ''
|
||||||
try {
|
try {
|
||||||
await addDownload(url, filename, info.pageUrl, '')
|
await addDownload(url, filename, info.pageUrl, '')
|
||||||
await notify('已添加到 Thing', `${filename || url}`)
|
} catch (e) { /* 忽略:添加失败时不打扰用户 */ }
|
||||||
} catch (e) {
|
|
||||||
await notify('Thing 添加失败', `${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Thing Extension",
|
"name": "Thing Extension",
|
||||||
"version": "0.2.0",
|
"version": "0.30",
|
||||||
"description": "发送浏览器下载到 Thing 下载引擎,嗅探网页资源。",
|
"description": "发送浏览器下载到 Thing 下载引擎,嗅探网页资源。",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
@@ -11,9 +11,7 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"downloads",
|
"downloads",
|
||||||
"storage",
|
"storage",
|
||||||
"notifications",
|
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"webNavigation",
|
|
||||||
"contextMenus",
|
"contextMenus",
|
||||||
"tabs",
|
"tabs",
|
||||||
"scripting"
|
"scripting"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@
|
|||||||
<span>启用资源嗅探</span>
|
<span>启用资源嗅探</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" id="showNotifications" />
|
|
||||||
<span>显示桌面通知</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>下载最小文件大小(字节,0=全部)</span>
|
<span>下载最小文件大小(字节,0=全部)</span>
|
||||||
<input type="number" id="minSize" min="0" placeholder="0" />
|
<input type="number" id="minSize" min="0" placeholder="0" />
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const DEFAULT_CONFIG = {
|
|||||||
interceptDownload: true,
|
interceptDownload: true,
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
excludeDomains: [],
|
excludeDomains: [],
|
||||||
showNotifications: true,
|
|
||||||
sniffEnabled: true,
|
sniffEnabled: true,
|
||||||
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
|
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
|
||||||
sniffMaxItems: 200,
|
sniffMaxItems: 200,
|
||||||
@@ -57,7 +56,6 @@ function fillForm(config) {
|
|||||||
$('secret').value = config.secret || ''
|
$('secret').value = config.secret || ''
|
||||||
$('interceptDownload').checked = config.interceptDownload !== false
|
$('interceptDownload').checked = config.interceptDownload !== false
|
||||||
$('sniffEnabled').checked = config.sniffEnabled !== false
|
$('sniffEnabled').checked = config.sniffEnabled !== false
|
||||||
$('showNotifications').checked = config.showNotifications !== false
|
|
||||||
$('minSize').value = config.minSize || 0
|
$('minSize').value = config.minSize || 0
|
||||||
$('sniffMinSize').value = config.sniffMinSize ?? DEFAULT_CONFIG.sniffMinSize
|
$('sniffMinSize').value = config.sniffMinSize ?? DEFAULT_CONFIG.sniffMinSize
|
||||||
$('excludeDomains').value = (config.excludeDomains || []).join(',')
|
$('excludeDomains').value = (config.excludeDomains || []).join(',')
|
||||||
@@ -69,7 +67,6 @@ function readForm() {
|
|||||||
secret: $('secret').value.trim(),
|
secret: $('secret').value.trim(),
|
||||||
interceptDownload: $('interceptDownload').checked,
|
interceptDownload: $('interceptDownload').checked,
|
||||||
sniffEnabled: $('sniffEnabled').checked,
|
sniffEnabled: $('sniffEnabled').checked,
|
||||||
showNotifications: $('showNotifications').checked,
|
|
||||||
minSize: parseInt($('minSize').value, 10) || 0,
|
minSize: parseInt($('minSize').value, 10) || 0,
|
||||||
sniffMinSize: parseInt($('sniffMinSize').value, 10) || 0,
|
sniffMinSize: parseInt($('sniffMinSize').value, 10) || 0,
|
||||||
excludeDomains: $('excludeDomains').value
|
excludeDomains: $('excludeDomains').value
|
||||||
|
|||||||
Binary file not shown.
@@ -7,7 +7,7 @@ use specta::Type;
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
use super::manager::{ClipboardManager, ClipboardSettings};
|
use super::manager::{ClipboardManager, ClipboardSettings};
|
||||||
use super::reader::dib_to_png;
|
use super::reader::{dib_to_png, dib_to_thumbnail};
|
||||||
use super::storage::{ClipboardItem, ClipboardItemDetail};
|
use super::storage::{ClipboardItem, ClipboardItemDetail};
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
#[derive(Serialize, Type)]
|
||||||
@@ -95,6 +95,21 @@ pub async fn clipboard_get_item(
|
|||||||
.map_err(|e| format!("查询任务失败: {}", e))
|
.map_err(|e| format!("查询任务失败: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取图片缩略图 PNG base64(弹窗悬停预览用,避免加载全尺寸图片)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn clipboard_get_thumb(
|
||||||
|
id: i64,
|
||||||
|
manager: State<'_, ClipboardManager>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let storage = manager.storage().clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
storage.get_thumb(id, |dib| dib_to_thumbnail(dib, 256))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("查询任务失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn clipboard_set_pinned(
|
pub async fn clipboard_set_pinned(
|
||||||
@@ -162,25 +177,44 @@ pub async fn clipboard_save_settings(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
manager: State<'_, ClipboardManager>,
|
manager: State<'_, ClipboardManager>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let prev_enabled = manager.get_settings().enabled;
|
let prev = manager.get_settings();
|
||||||
let prev_shortcut = manager.get_settings().shortcut.clone();
|
let prev_enabled = prev.enabled;
|
||||||
|
let prev_shortcut = prev.shortcut.clone();
|
||||||
|
|
||||||
|
// 快捷键变化且新值非空:先注册(原子化 + 冲突检测),成功后才保存设置。
|
||||||
|
// 注册失败时恢复旧快捷键并中止保存,避免设置被写入无法生效的组合键、旧键丢失。
|
||||||
|
if settings.shortcut != prev_shortcut && !settings.shortcut.trim().is_empty() {
|
||||||
|
if let Err(e) = crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
|
||||||
|
super::popup::show_popup(a)
|
||||||
|
}) {
|
||||||
|
// register_shortcut 内部已注销旧快捷键,失败时需重新注册旧键以恢复
|
||||||
|
if !prev_shortcut.trim().is_empty() {
|
||||||
|
let _ = crate::shortcut::register_shortcut(&app, "剪贴板", &prev_shortcut, |a| {
|
||||||
|
super::popup::show_popup(a)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
// 新快捷键非空时确保弹窗/预览窗口已预创建
|
||||||
|
super::popup::ensure_popup_window(&app);
|
||||||
|
super::popup::ensure_preview_window(&app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存设置
|
||||||
manager.save_settings(settings.clone());
|
manager.save_settings(settings.clone());
|
||||||
|
|
||||||
// 监听开关变化时联动启停
|
// 监听开关变化时联动启停
|
||||||
if settings.enabled && !prev_enabled {
|
if settings.enabled && !prev_enabled {
|
||||||
manager.start(&app);
|
manager.start(&app);
|
||||||
} else if !settings.enabled && prev_enabled {
|
} else if !settings.enabled && prev_enabled {
|
||||||
manager.stop();
|
manager.stop();
|
||||||
}
|
}
|
||||||
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
|
|
||||||
if settings.shortcut != prev_shortcut {
|
// 快捷键改为空字符串(禁用):注销旧快捷键
|
||||||
crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
|
if settings.shortcut.trim().is_empty() && settings.shortcut != prev_shortcut {
|
||||||
super::popup::show_popup(a)
|
crate::shortcut::unregister_shortcut(&app, "剪贴板");
|
||||||
})?;
|
|
||||||
// 新快捷键非空时确保弹窗窗口已预创建
|
|
||||||
if !settings.shortcut.trim().is_empty() {
|
|
||||||
super::popup::ensure_popup_window(&app);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,3 +296,52 @@ pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
|
|||||||
super::popup::paste_to_target(&app);
|
super::popup::paste_to_target(&app);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在弹窗旁显示独立预览窗口(悬停/键盘选中时调用)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn clipboard_show_preview(app: AppHandle, id: i64) -> Result<(), String> {
|
||||||
|
super::popup::show_preview(&app, id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏独立预览窗口
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn clipboard_hide_preview(app: AppHandle) -> Result<(), String> {
|
||||||
|
super::popup::hide_preview(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按内容自适应调整预览窗大小(逻辑像素)。前端加载内容(文本测高、图片按宽高比)后调用,
|
||||||
|
/// 窗口贴合内容消除留白;后端按弹窗所在屏工作区钳制并重新对齐弹窗。
|
||||||
|
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false(保持原侧)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn clipboard_resize_preview(
|
||||||
|
app: AppHandle,
|
||||||
|
width: f64,
|
||||||
|
height: f64,
|
||||||
|
allow_flip: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
super::popup::resize_preview(&app, width, height, allow_flip);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示已就绪的预览窗口。前端完成内容加载与 resize 后调用,窗口以最终尺寸出现,
|
||||||
|
/// 消除"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn clipboard_reveal_preview(app: AppHandle) -> Result<(), String> {
|
||||||
|
super::popup::reveal_preview(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览窗交互锁定:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
|
||||||
|
/// 此后弹窗+预览不因失焦/鼠标离开而关闭,仅当点击外部或弹窗重新聚焦时退出锁定。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn clipboard_preview_interacted(app: AppHandle) -> Result<(), String> {
|
||||||
|
super::popup::preview_interacted(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use specta::Type;
|
|||||||
use super::monitor::start_monitor;
|
use super::monitor::start_monitor;
|
||||||
use super::reader::{write_dib, write_files, write_text};
|
use super::reader::{write_dib, write_files, write_text};
|
||||||
use super::storage::Storage;
|
use super::storage::Storage;
|
||||||
|
use super::suppress::SuppressState;
|
||||||
|
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
||||||
|
|
||||||
/// 剪贴板设置(持久化到 clipboard/settings.json)
|
/// 剪贴板设置(持久化到 clipboard/settings.json)
|
||||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||||
@@ -55,7 +57,9 @@ impl Default for ClipboardSettings {
|
|||||||
pub struct ClipboardManager {
|
pub struct ClipboardManager {
|
||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
settings: Arc<Mutex<ClipboardSettings>>,
|
settings: Arc<Mutex<ClipboardSettings>>,
|
||||||
suppress: Arc<AtomicBool>,
|
/// 写入抑制状态。由本模块持有(谁启动监听谁负责),但**对其他模块开放**:
|
||||||
|
/// 划词取词会连续改三次剪贴板,同样需要屏蔽,见 `super::suppress`。
|
||||||
|
suppress: Arc<SuppressState>,
|
||||||
monitor_stop: Arc<AtomicBool>,
|
monitor_stop: Arc<AtomicBool>,
|
||||||
monitor_handle: Mutex<Option<JoinHandle<()>>>,
|
monitor_handle: Mutex<Option<JoinHandle<()>>>,
|
||||||
settings_path: PathBuf,
|
settings_path: PathBuf,
|
||||||
@@ -73,7 +77,7 @@ impl ClipboardManager {
|
|||||||
};
|
};
|
||||||
let settings_path = clip_dir.join("settings.json");
|
let settings_path = clip_dir.join("settings.json");
|
||||||
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
|
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
|
||||||
let suppress = Arc::new(AtomicBool::new(false));
|
let suppress = Arc::new(SuppressState::new());
|
||||||
let monitor_stop = Arc::new(AtomicBool::new(true));
|
let monitor_stop = Arc::new(AtomicBool::new(true));
|
||||||
Self {
|
Self {
|
||||||
storage,
|
storage,
|
||||||
@@ -139,13 +143,12 @@ impl ClipboardManager {
|
|||||||
save_settings(&self.settings_path, &s);
|
save_settings(&self.settings_path, &s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录。
|
/// 将某条历史写回剪贴板。写回成功后记录剪贴板序列号,供监听跳过自身写入。
|
||||||
pub fn copy_back(&self, id: i64) -> Result<(), String> {
|
pub fn copy_back(&self, id: i64) -> Result<(), String> {
|
||||||
let (kind, content, blob) = self
|
let (kind, content, blob) = self
|
||||||
.storage
|
.storage
|
||||||
.get_raw_for_copy(id)
|
.get_raw_for_copy(id)
|
||||||
.ok_or_else(|| "条目不存在".to_string())?;
|
.ok_or_else(|| "条目不存在".to_string())?;
|
||||||
self.suppress.store(true, Ordering::SeqCst);
|
|
||||||
let ok = match kind.as_str() {
|
let ok = match kind.as_str() {
|
||||||
"text" => content.as_deref().map(write_text).unwrap_or(false),
|
"text" => content.as_deref().map(write_text).unwrap_or(false),
|
||||||
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
|
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
|
||||||
@@ -163,10 +166,12 @@ impl ClipboardManager {
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if ok {
|
if ok {
|
||||||
|
// 绑定到写入完成后的剪贴板序列号:仅跳过本次写入产生的记录,
|
||||||
|
// 用户后续复制(序列号不同)不会被误吞。
|
||||||
|
let seq = unsafe { GetClipboardSequenceNumber() };
|
||||||
|
self.suppress.mark_seq(seq);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// 写入失败也清除 suppress,避免误吞下次复制
|
|
||||||
self.suppress.store(false, Ordering::SeqCst);
|
|
||||||
Err("写回剪贴板失败".into())
|
Err("写回剪贴板失败".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +179,20 @@ impl ClipboardManager {
|
|||||||
pub fn storage(&self) -> &Arc<Storage> {
|
pub fn storage(&self) -> &Arc<Storage> {
|
||||||
&self.storage
|
&self.storage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写入抑制状态(供划词取词等会改剪贴板的其他模块共享)
|
||||||
|
pub fn suppress(&self) -> Arc<SuppressState> {
|
||||||
|
self.suppress.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把**当前**剪贴板序列号登记为「应跳过」。
|
||||||
|
///
|
||||||
|
/// 给其他模块写完剪贴板后调用:它们自己拿不到「写入后」的序列号,
|
||||||
|
/// 但知道「刚刚写完」这件事。把 win32 调用留在这个模块里,
|
||||||
|
/// 别处就不必重复引入 DataExchange。
|
||||||
|
pub fn suppress_current_sequence(&self) {
|
||||||
|
self.suppress.mark_seq(unsafe { GetClipboardSequenceNumber() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for ClipboardManager {
|
impl Drop for ClipboardManager {
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ pub mod monitor;
|
|||||||
pub mod popup;
|
pub mod popup;
|
||||||
pub mod reader;
|
pub mod reader;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod suppress;
|
||||||
|
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
|
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
|
||||||
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
|
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
|
||||||
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
|
clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
|
||||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
clipboard_preview_interacted, clipboard_register_shortcut,
|
||||||
|
clipboard_reveal_preview,
|
||||||
|
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
|
||||||
|
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
|
||||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||||
};
|
};
|
||||||
pub use manager::ClipboardManager;
|
pub use manager::ClipboardManager;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
|
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
|
||||||
//!
|
//!
|
||||||
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
|
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
|
||||||
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
|
//! 250ms 间隔兼顾响应速度与开销,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -12,21 +12,24 @@ use tauri::{AppHandle, Emitter};
|
|||||||
|
|
||||||
use super::reader::{read_clipboard, ClipData};
|
use super::reader::{read_clipboard, ClipData};
|
||||||
use super::storage::{NewItem, Storage};
|
use super::storage::{NewItem, Storage};
|
||||||
|
use super::suppress::SuppressState;
|
||||||
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
||||||
|
|
||||||
/// 启动监听线程,返回 JoinHandle。
|
/// 启动监听线程,返回 JoinHandle。
|
||||||
|
/// `suppress` 记录本应用写入剪贴板产生的序列号、以及取词等流程的屏蔽窗口,
|
||||||
|
/// 用于跳过自身写入产生的记录。
|
||||||
pub fn start_monitor(
|
pub fn start_monitor(
|
||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
|
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
|
||||||
suppress: Arc<AtomicBool>,
|
suppress: Arc<SuppressState>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) -> thread::JoinHandle<()> {
|
) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || loop {
|
thread::spawn(move || loop {
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(800));
|
thread::sleep(Duration::from_millis(250));
|
||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -40,9 +43,9 @@ pub fn start_monitor(
|
|||||||
if seq == last {
|
if seq == last {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 序列号变化,处理一次
|
// 序列号变化,但可能来自本应用:精确记账(copy_back)或屏蔽窗口内(取词流程)。
|
||||||
if suppress.swap(false, Ordering::SeqCst) {
|
// 判定放在读剪贴板之前,避免为一次注定要丢弃的变化做无谓的读取与解码。
|
||||||
// 由本应用 copy_back 触发,跳过记录
|
if suppress.should_skip(seq) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
|
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
|
||||||
@@ -99,6 +102,7 @@ fn build_text_item(t: &str) -> NewItem {
|
|||||||
kind: "text".into(),
|
kind: "text".into(),
|
||||||
content: Some(t.to_string()),
|
content: Some(t.to_string()),
|
||||||
blob: None,
|
blob: None,
|
||||||
|
thumb: None,
|
||||||
preview: make_preview(t, 200),
|
preview: make_preview(t, 200),
|
||||||
size: t.len() as i64,
|
size: t.len() as i64,
|
||||||
hash: hash_str(t),
|
hash: hash_str(t),
|
||||||
@@ -110,6 +114,7 @@ fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
|
|||||||
kind: "image".into(),
|
kind: "image".into(),
|
||||||
content: None,
|
content: None,
|
||||||
blob: Some(dib.to_vec()),
|
blob: Some(dib.to_vec()),
|
||||||
|
thumb: super::reader::dib_to_thumbnail(dib, 256),
|
||||||
preview: format!("图片 {}×{}", w, h),
|
preview: format!("图片 {}×{}", w, h),
|
||||||
size: dib.len() as i64,
|
size: dib.len() as i64,
|
||||||
hash: hash_bytes(dib),
|
hash: hash_bytes(dib),
|
||||||
@@ -132,6 +137,7 @@ fn build_files_item(files: &[String]) -> NewItem {
|
|||||||
kind: "files".into(),
|
kind: "files".into(),
|
||||||
content: Some(content),
|
content: Some(content),
|
||||||
blob: None,
|
blob: None,
|
||||||
|
thumb: None,
|
||||||
preview,
|
preview,
|
||||||
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
|
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
|
||||||
hash,
|
hash,
|
||||||
|
|||||||
@@ -10,12 +10,27 @@
|
|||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
|
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
|
||||||
use tauri::window::{Effect, EffectsBuilder};
|
use tauri::window::{Effect, EffectsBuilder};
|
||||||
|
|
||||||
/// 弹窗窗口标签
|
/// 弹窗窗口标签
|
||||||
pub const POPUP_LABEL: &str = "clipboard-popup";
|
pub const POPUP_LABEL: &str = "clipboard-popup";
|
||||||
|
|
||||||
|
/// 预览窗口标签(悬停/键盘选中条目时在弹窗旁显示内容)
|
||||||
|
pub const PREVIEW_LABEL: &str = "clipboard-preview";
|
||||||
|
|
||||||
|
/// 预览窗口初始逻辑尺寸(后续由前端按内容自适应调整,见 resize_preview)
|
||||||
|
const PREVIEW_W: f64 = 340.0;
|
||||||
|
const PREVIEW_H: f64 = 300.0;
|
||||||
|
|
||||||
|
/// 预览窗口最小逻辑尺寸(防止内容过小时窗口退化)
|
||||||
|
const PREVIEW_MIN_W: f64 = 240.0;
|
||||||
|
const PREVIEW_MIN_H: f64 = 160.0;
|
||||||
|
|
||||||
|
/// 弹窗与预览窗之间的可视间距(逻辑像素,左右对称)
|
||||||
|
const PREVIEW_GAP: f64 = 12.0;
|
||||||
|
|
||||||
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
|
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
|
||||||
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
|
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
|
||||||
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
||||||
@@ -24,6 +39,50 @@ static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
|||||||
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
|
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
|
||||||
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
|
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 弹窗当前是否可见(同步单一事实来源)。
|
||||||
|
/// show_preview 用它代替 popup.is_visible():后者在 hide 异步在途时可能返回过期 true,
|
||||||
|
/// 导致"弹窗已隐藏但残留的悬停/键盘请求重新弹出孤立预览窗"。该标志在隐藏路径
|
||||||
|
/// 同步置 false,任何在途请求都会直接拒绝。
|
||||||
|
static POPUP_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 预览窗当前是否可见(同步标志,与 POPUP_VISIBLE 同理:预览窗以原生
|
||||||
|
/// SW_SHOWNOACTIVATE 显示,Tauri 的 is_visible() 可能与其真实状态不同步,
|
||||||
|
/// 故用该标志代替 is_visible 判断预览交互状态)。
|
||||||
|
static PREVIEW_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 弹窗因预览交互(光标位于预览窗内)而推迟隐藏的标记:
|
||||||
|
/// 点击预览窗(选择/复制文本、放大图片)时,弹窗会收到 Focused(false),但用户正在
|
||||||
|
/// 预览窗内交互,此时不应隐藏弹窗。置该标记后保持弹窗显示,待用户离开预览窗
|
||||||
|
/// (hide_preview)时再连同弹窗一起隐藏,避免"点击预览即关闭弹窗"导致无法交互。
|
||||||
|
static POPUP_DEFER_HIDE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 失焦隐藏的宽限期:WebView2 透明窗口在 show 后激活期间焦点可能短暂弹跳
|
||||||
|
/// (与快速面板同因),导致 Focused(false) 紧跟在 show 之后触发并立即隐藏刚显示的弹窗。
|
||||||
|
const SHOW_GRACE: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// 最近一次 show 的时间,用于失焦宽限期判断。
|
||||||
|
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
|
||||||
|
fn mark_shown() {
|
||||||
|
if let Ok(mut t) = LAST_SHOWN.lock() {
|
||||||
|
*t = Some(Instant::now());
|
||||||
|
}
|
||||||
|
POPUP_VISIBLE.store(true, Ordering::SeqCst);
|
||||||
|
// 重新显示弹窗时复位推迟隐藏标记(上一轮预览交互的遗留状态不应影响本轮)
|
||||||
|
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
|
||||||
|
fn within_show_grace() -> bool {
|
||||||
|
LAST_SHOWN
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| *t)
|
||||||
|
.map(|t| t.elapsed() < SHOW_GRACE)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
|
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
|
||||||
/// 位置默认在屏幕外,show_popup 时会重新定位。
|
/// 位置默认在屏幕外,show_popup 时会重新定位。
|
||||||
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
|
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
|
||||||
@@ -54,13 +113,41 @@ fn create_popup_window(app: &AppHandle) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听窗口失焦:自动隐藏
|
// 监听窗口失焦:自动隐藏(同时隐藏预览窗)
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let win_handle = win.clone();
|
let win_handle = win.clone();
|
||||||
win.on_window_event(move |event| {
|
win.on_window_event(move |event| {
|
||||||
if let tauri::WindowEvent::Focused(false) = event {
|
match event {
|
||||||
let _ = win_handle.hide();
|
tauri::WindowEvent::Focused(false) => {
|
||||||
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
|
// 失焦宽限期:show 后激活期间的焦点弹跳不隐藏弹窗,避免弹窗刚显示就被隐藏
|
||||||
|
if within_show_grace() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 交互锁定模式(点击过预览窗):弹窗与预览保持显示,仅由看护线程在
|
||||||
|
// "点击外部"时关闭;此处的失焦是点击预览窗(NoActivate)所致,不隐藏。
|
||||||
|
if POPUP_INTERACTED.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 鼠标当前位于预览窗或弹窗内(用户正在交互:选择/复制文本、放大图片、或
|
||||||
|
// 已移回弹窗准备继续浏览):弹窗失焦是点击预览窗或焦点反弹所致,不应隐藏弹窗。
|
||||||
|
// 置推迟标记保持弹窗显示,待用户离开整个区域(hide_preview / 再次失焦)再隐藏。
|
||||||
|
if is_cursor_in_preview(&app_handle) || is_cursor_in_popup(&app_handle) {
|
||||||
|
POPUP_DEFER_HIDE.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 先同步标记弹窗不可见,再隐藏窗口/预览:异步 hide 在途时,残留的
|
||||||
|
// show_preview 请求会因该标志为 false 而拒绝,预览窗不会孤立残留。
|
||||||
|
POPUP_VISIBLE.store(false, Ordering::SeqCst);
|
||||||
|
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||||
|
let _ = win_handle.hide();
|
||||||
|
hide_preview(&app_handle);
|
||||||
|
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
|
||||||
|
}
|
||||||
|
tauri::WindowEvent::Focused(true) => {
|
||||||
|
// 弹窗重新获得焦点(用户点击/移回弹窗):取消推迟隐藏状态
|
||||||
|
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,6 +199,7 @@ pub fn show_popup(app: &AppHandle) {
|
|||||||
y: y as i32,
|
y: y as i32,
|
||||||
}));
|
}));
|
||||||
let _ = win.show();
|
let _ = win.show();
|
||||||
|
mark_shown();
|
||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
// 通知前端刷新数据
|
// 通知前端刷新数据
|
||||||
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
|
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
|
||||||
@@ -143,17 +231,27 @@ pub fn show_window(app: &AppHandle) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let _ = win.show();
|
let _ = win.show();
|
||||||
|
mark_shown();
|
||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
// 通知前端刷新数据
|
// 通知前端刷新数据
|
||||||
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
|
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 隐藏弹窗(不销毁,保留复用)
|
/// 隐藏弹窗(不销毁,保留复用),并通知前端清理悬停定时器/预览。
|
||||||
pub fn hide_popup(app: &AppHandle) {
|
pub fn hide_popup(app: &AppHandle) {
|
||||||
|
// 复位推迟隐藏标记(先于 hide_preview,避免 hide_preview 重入 hide_popup 形成递归)
|
||||||
|
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||||
|
// 复位交互锁定:弹窗关闭后下次呼出恢复"失焦即隐藏"的常规模式
|
||||||
|
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||||
|
// 同步标记弹窗不可见,拒绝此后在途的 show_preview 请求
|
||||||
|
POPUP_VISIBLE.store(false, Ordering::SeqCst);
|
||||||
|
hide_preview(app);
|
||||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
let _ = win.hide();
|
let _ = win.hide();
|
||||||
}
|
}
|
||||||
|
// 通知前端:取消悬停定时器并隐藏预览,避免弹窗隐藏后残留定时器重新弹出预览窗
|
||||||
|
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
|
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
|
||||||
@@ -207,6 +305,434 @@ fn simulate_paste() {
|
|||||||
// 非 Windows 平台暂不支持自动粘贴
|
// 非 Windows 平台暂不支持自动粘贴
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 独立预览窗口 =====
|
||||||
|
|
||||||
|
/// 创建预览窗口(隐藏状态),应用 NoActivate 样式避免抢焦点。
|
||||||
|
fn create_preview_window(app: &AppHandle) {
|
||||||
|
let win = match WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
PREVIEW_LABEL,
|
||||||
|
WebviewUrl::App("index.html#clipboard-preview".into()),
|
||||||
|
)
|
||||||
|
.title("预览")
|
||||||
|
.inner_size(PREVIEW_W, PREVIEW_H)
|
||||||
|
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
|
.shadow(true)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.resizable(false)
|
||||||
|
.visible(false)
|
||||||
|
.focused(false) // 不抢占焦点
|
||||||
|
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_error("clipboard", &format!("创建预览窗失败: {}", e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NoActivate:预览窗可交互但不激活,弹窗保持前台(键盘导航/粘贴不受影响)
|
||||||
|
// 圆角:NoActivate 悬浮窗系统不自动圆角,显式指定 DWMWCP_ROUND 与弹窗外观统一
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::apply_no_activate(hwnd.0 as isize);
|
||||||
|
crate::win32_util::apply_rounded_corners(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::logger::log_info("clipboard", "预览窗口已预创建(隐藏状态)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用启动时预创建预览窗口(隐藏),随弹窗一起就绪。
|
||||||
|
pub fn ensure_preview_window(app: &AppHandle) {
|
||||||
|
if app.get_webview_window(PREVIEW_LABEL).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
create_preview_window(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览当前所在侧(default=右,left=左),供放大/还原重定位沿用同侧。
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum PreviewSide {
|
||||||
|
Right,
|
||||||
|
Left,
|
||||||
|
}
|
||||||
|
|
||||||
|
static PREVIEW_SIDE: Mutex<Option<PreviewSide>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 最近一次显示预览时的鼠标 Y 锚点(物理像素)。预览窗垂直中线对齐该位置
|
||||||
|
/// (贴近鼠标所悬停的条目),resize(切换条目/放大还原)时沿用,保持中线稳定不跳变。
|
||||||
|
static PREVIEW_ANCHOR_Y: Mutex<Option<i32>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 弹窗离开看护线程是否已在运行(防重入,见 spawn_popup_leave_watch)。
|
||||||
|
static POPUP_LEAVE_WATCH: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 交互锁定模式:用户点击过预览窗(放大/缩小、复制文本等)后置位。
|
||||||
|
/// 该模式下弹窗与预览不因失焦/鼠标离开而关闭,仅当点击弹窗+预览之外的
|
||||||
|
/// 区域(看护线程检测鼠标按下沿)或弹窗重新聚焦(恢复正常模式)时才复位。
|
||||||
|
static POPUP_INTERACTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 重新定位预览窗口(物理像素)。preferred 指定优先放置侧,返回实际落位侧。
|
||||||
|
/// anchor_y 为鼠标 Y 锚点:预览窗垂直中线对齐该位置(贴近悬停条目),
|
||||||
|
/// 上下 clamp 到工作区;None 时退回弹窗顶部对齐。
|
||||||
|
///
|
||||||
|
/// 无边框+阴影窗口(tao 实现)的外框矩形在客户区四周各留一条不可见边框
|
||||||
|
/// (SM_CXSIZEFRAME+SM_CXPADDEDBORDER,典型 8px):outer_position/outer_size 均含该边框,
|
||||||
|
/// 而可视内容仅占客户区。若直接用外框尺寸计算右侧落位,右侧可视间距会比左侧多出
|
||||||
|
/// 两侧边框之和(约 16px)。这里以"可视边缘"对齐:frame = (外框宽 - 客户区宽) / 2,
|
||||||
|
/// 左右两侧使用同一 GAP,保证两侧可视间距一致。
|
||||||
|
fn reposition_preview(
|
||||||
|
popup: &tauri::WebviewWindow,
|
||||||
|
win: &tauri::WebviewWindow,
|
||||||
|
vw: i32,
|
||||||
|
vh: i32,
|
||||||
|
preferred: PreviewSide,
|
||||||
|
anchor_y: Option<i32>,
|
||||||
|
allow_flip: bool,
|
||||||
|
) -> PreviewSide {
|
||||||
|
// 弹窗当前物理位置与尺寸(外框,含不可见边框)
|
||||||
|
let Ok(pos) = popup.outer_position() else { return preferred };
|
||||||
|
let Ok(osz) = popup.outer_size() else { return preferred };
|
||||||
|
let Ok(isz) = popup.inner_size() else { return preferred };
|
||||||
|
let (px, py) = (pos.x, pos.y);
|
||||||
|
let pw = osz.width as i32;
|
||||||
|
// 不可见边框宽度(外框与客户区之差的一半;无阴影窗口为 0)
|
||||||
|
let frame = ((osz.width.saturating_sub(isz.width)) / 2) as i32;
|
||||||
|
|
||||||
|
// 弹窗所在显示器工作区
|
||||||
|
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(px, py)
|
||||||
|
.unwrap_or((0, 0, 1920, 1040));
|
||||||
|
|
||||||
|
let gap = (PREVIEW_GAP * popup.scale_factor().unwrap_or(1.0)).round() as i32;
|
||||||
|
|
||||||
|
// 可视边缘对齐:
|
||||||
|
// 右侧落位 = 弹窗可视右缘(px+pw-frame) + GAP - 预览窗自身左边框(frame)
|
||||||
|
// 左侧落位 = 弹窗可视左缘(px+frame) - GAP - 预览可视宽(vw) - 预览窗左边框(frame)
|
||||||
|
// (frame 两两抵消,与旧公式一致)
|
||||||
|
let right_x = px + pw - 2 * frame + gap;
|
||||||
|
let left_x = px - gap - vw;
|
||||||
|
// 可视区域(含预览窗自身边框偏移)不越工作区
|
||||||
|
let fits_right = right_x + frame + vw <= wa_right;
|
||||||
|
let fits_left = left_x + frame >= wa_left;
|
||||||
|
let mut x = match preferred {
|
||||||
|
PreviewSide::Right => right_x,
|
||||||
|
PreviewSide::Left => left_x,
|
||||||
|
};
|
||||||
|
// 仅初始落位(allow_flip=true)允许换侧:优先侧放不下时切到另一侧。
|
||||||
|
// 内容尺寸变化(放大/还原,allow_flip=false)不允许换侧:窗口跳到另一侧会使
|
||||||
|
// 鼠标瞬间落在窗外,误触发 mouseleave 而关闭预览/弹窗;此时保持原侧并靠
|
||||||
|
// 工作区 clamp(大图可能覆盖弹窗边缘,属合理取舍,鼠标仍在预览窗内可交互)。
|
||||||
|
if allow_flip {
|
||||||
|
match preferred {
|
||||||
|
PreviewSide::Right => {
|
||||||
|
if !fits_right {
|
||||||
|
x = left_x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PreviewSide::Left => {
|
||||||
|
if !fits_left {
|
||||||
|
x = right_x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x = (x + frame).max(wa_left).min(wa_right - vw) - frame;
|
||||||
|
// 仅初始落位(allow_flip)按最终位置更新所在侧(换侧时位置在弹窗另一侧,判定正确);
|
||||||
|
// 放大/还原(不允许换侧)时即使被工作区 clamp 覆盖到弹窗上方,语义上仍属原侧,
|
||||||
|
// 必须沿用 preferred——否则 clamp 后按位置误判为另一侧并存档,缩小后会跳侧。
|
||||||
|
let side = if allow_flip {
|
||||||
|
if x + vw <= px { PreviewSide::Left } else { PreviewSide::Right }
|
||||||
|
} else {
|
||||||
|
preferred
|
||||||
|
};
|
||||||
|
// 垂直定位:预览窗中线对齐鼠标 Y 锚点(贴近悬停条目),上下 clamp 到工作区;
|
||||||
|
// 无锚点时退回弹窗顶部对齐
|
||||||
|
let y = match anchor_y {
|
||||||
|
Some(ay) => (ay - vh / 2).max(wa_top).min(wa_bottom - vh),
|
||||||
|
None => py.max(wa_top).min(wa_bottom - vh),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||||
|
if let Ok(mut s) = PREVIEW_SIDE.lock() {
|
||||||
|
*s = Some(side);
|
||||||
|
}
|
||||||
|
side
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知预览前端加载指定条目。窗口此时保持隐藏:前端测得内容尺寸后先调用
|
||||||
|
/// resize_preview(隐藏状态下完成尺寸/位置调整)再 reveal_preview 显示,
|
||||||
|
/// 避免"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
|
||||||
|
pub fn show_preview(app: &AppHandle, id: i64) {
|
||||||
|
// 弹窗已隐藏(或隐藏中)时拒绝显示预览:同步标志消除 is_visible() 的异步时序竞态,
|
||||||
|
// 保证"弹窗失焦消失后预览窗不会孤立残留"。
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if app.get_webview_window(PREVIEW_LABEL).is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// 弹窗不可见时不显示预览:避免弹窗隐藏后仍在途的悬停请求重新弹出孤立的预览窗
|
||||||
|
if !popup.is_visible().unwrap_or(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录鼠标 Y 锚点:预览窗中线对齐鼠标所悬停的条目位置;resize 时沿用
|
||||||
|
if let Some((_, y)) = get_cursor_pos() {
|
||||||
|
if let Ok(mut a) = PREVIEW_ANCHOR_Y.lock() {
|
||||||
|
*a = Some(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知前端加载条目内容(前端完成后自行 resize + reveal)
|
||||||
|
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_SHOW, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示已就绪的预览窗口(前端完成内容加载与 resize 后调用)。
|
||||||
|
/// 显示前重查弹窗可见性,消除内容加载期间弹窗已隐藏的竞态。
|
||||||
|
pub fn reveal_preview(app: &AppHandle) {
|
||||||
|
// 弹窗已隐藏:在途请求作废
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 已可见则跳过(放大/还原触发的重复 reveal)
|
||||||
|
if PREVIEW_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步标记预览窗可见(以原生方式显示,Tauri 的 is_visible 可能不同步)
|
||||||
|
PREVIEW_VISIBLE.store(true, Ordering::SeqCst);
|
||||||
|
// 不激活显示(SW_SHOWNOACTIVATE),弹窗保持前台
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::show_no_activate(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = win.show();
|
||||||
|
|
||||||
|
// 显示期间弹窗可能已被隐藏(失焦/关闭):立即收回预览。
|
||||||
|
// 关闭初次检查与真正 show 之间的竞态——失焦处理器先置 POPUP_VISIBLE=false
|
||||||
|
// 再隐藏,这里 show 之后重查一次,若已被隐藏则收回预览,避免孤立残留。
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
|
||||||
|
let _ = win.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按内容自适应调整预览窗大小(逻辑像素),位置沿用当前所在侧并重新对齐弹窗。
|
||||||
|
/// 前端加载/切换内容(文本测高、图片按宽高比)后调用,窗口贴合内容消除留白。
|
||||||
|
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false
|
||||||
|
/// (保持原侧靠工作区 clamp,避免窗口跳侧使鼠标落在窗外误触发隐藏)。
|
||||||
|
pub fn resize_preview(app: &AppHandle, width: f64, height: f64, allow_flip: bool) {
|
||||||
|
// 弹窗不可见时忽略(无对齐基准)
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let scale = popup.scale_factor().unwrap_or(1.0);
|
||||||
|
// 以弹窗所在屏工作区为上限钳制(逻辑像素),并保证不小于最小可视尺寸
|
||||||
|
let (wa_left, wa_top, wa_right, wa_bottom) = popup
|
||||||
|
.outer_position()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| get_work_area_at_point(p.x, p.y))
|
||||||
|
.unwrap_or((0, 0, 1920, 1040));
|
||||||
|
let max_w = ((wa_right - wa_left).max(0) as f64 / scale).floor().max(PREVIEW_MIN_W);
|
||||||
|
let max_h = ((wa_bottom - wa_top).max(0) as f64 / scale).floor().max(PREVIEW_MIN_H);
|
||||||
|
let w = width.clamp(PREVIEW_MIN_W, max_w);
|
||||||
|
let h = height.clamp(PREVIEW_MIN_H, max_h);
|
||||||
|
let vw = (w * scale).round() as i32;
|
||||||
|
let vh = (h * scale).round() as i32;
|
||||||
|
|
||||||
|
let side = PREVIEW_SIDE.lock().ok().and_then(|s| *s).unwrap_or(PreviewSide::Right);
|
||||||
|
// 沿用最近一次的鼠标 Y 锚点:高度变化时中线保持对齐悬停条目,不产生跳变
|
||||||
|
let anchor_y = PREVIEW_ANCHOR_Y.lock().ok().and_then(|a| *a);
|
||||||
|
reposition_preview(&popup, &win, vw, vh, side, anchor_y, allow_flip);
|
||||||
|
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize { width: vw as u32, height: vh as u32 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 交互锁定模式入口:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
|
||||||
|
/// 置位锁定标志并启动看护线程:此后弹窗+预览不因失焦/鼠标离开而关闭,
|
||||||
|
/// 仅当点击弹窗+预览之外的区域或弹窗重新聚焦时退出锁定。
|
||||||
|
pub fn preview_interacted(app: &AppHandle) {
|
||||||
|
POPUP_INTERACTED.store(true, Ordering::SeqCst);
|
||||||
|
// 交互锁定取代推迟隐藏模式:清除可能残留的 DEFER 标记,
|
||||||
|
// 避免后续取消钉住时 hide_preview 走 DEFER 分支误关弹窗
|
||||||
|
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
|
||||||
|
spawn_popup_leave_watch(app.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏预览窗口(保留复用),并通知前端清空内容。
|
||||||
|
pub fn hide_preview(app: &AppHandle) {
|
||||||
|
// 交互锁定模式:弹窗仍显示时预览保持(生命周期由看护线程管理,点击外部才
|
||||||
|
// 随弹窗一起关闭),忽略常规隐藏请求(条目离开、键盘切换等)。
|
||||||
|
if POPUP_INTERACTED.load(Ordering::SeqCst) && POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||||
|
// 若此前因预览交互推迟了弹窗隐藏(弹窗已失焦但保持显示),现在用户离开预览窗,
|
||||||
|
// 需判断去向:光标已回到弹窗内(继续浏览弹窗)→ 仅隐藏预览、保持弹窗显示;
|
||||||
|
// 光标在弹窗之外(离开整个区域)→ 连同弹窗一起隐藏。
|
||||||
|
// (hide_popup 会先复位该标记,故此处 swap(false) 后调用不会递归重入)
|
||||||
|
if POPUP_DEFER_HIDE.swap(false, Ordering::SeqCst) {
|
||||||
|
if !is_cursor_in_popup(app) {
|
||||||
|
hide_popup(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 光标已回到弹窗内。若弹窗仍持有焦点(曾点击弹窗重新激活),仅隐藏预览即可,
|
||||||
|
// 后续失焦仍走 Focused(false) 正常关闭;若弹窗已失焦(点击预览窗所致),
|
||||||
|
// 它不会再收到 Focused(false) 事件——光标一旦离开弹窗区域,弹窗将永远
|
||||||
|
// 无法关闭(无焦点残留窗口)。启动看护线程兜底关闭。
|
||||||
|
let focused = app
|
||||||
|
.get_webview_window(POPUP_LABEL)
|
||||||
|
.map(|w| w.is_focused().unwrap_or(false))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !focused {
|
||||||
|
spawn_popup_leave_watch(app.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
|
||||||
|
if let Some(win) = app.get_webview_window(PREVIEW_LABEL) {
|
||||||
|
let _ = win.hide();
|
||||||
|
// 预览窗以原生 SW_SHOWNOACTIVATE 方式显示,Tauri 内部可见性状态可能与其
|
||||||
|
// 不同步;补一次原生 SW_HIDE,确保任何路径下都被可靠隐藏。
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::hide_window(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_HIDE, ());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 弹窗失焦但保持显示时的看护线程(防重入单例)。
|
||||||
|
///
|
||||||
|
/// 两种工作模式(每轮动态读取 POPUP_INTERACTED,可中途切换):
|
||||||
|
/// - 常规模式(未点击过预览窗):光标连续 ~300ms 既不在弹窗也不在预览窗内
|
||||||
|
/// (连续计数防"穿越弹窗-预览间隙"时的瞬时离开误判)→ 关闭弹窗;
|
||||||
|
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
|
||||||
|
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
|
||||||
|
///
|
||||||
|
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
|
||||||
|
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
|
||||||
|
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
|
||||||
|
fn spawn_popup_leave_watch(app: AppHandle) {
|
||||||
|
// 防重入:已有看护在运行则跳过
|
||||||
|
if POPUP_LEAVE_WATCH.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut outside = 0u32;
|
||||||
|
let mut was_down = false;
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
|
// 弹窗已隐藏或被销毁:看护结束
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst)
|
||||||
|
|| app.get_webview_window(POPUP_LABEL).is_none()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 弹窗重新获得焦点(用户点击弹窗):恢复正常失焦关闭路径,复位交互锁定
|
||||||
|
if app
|
||||||
|
.get_webview_window(POPUP_LABEL)
|
||||||
|
.map(|w| w.is_focused().unwrap_or(false))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
POPUP_INTERACTED.store(false, Ordering::SeqCst);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
|
||||||
|
// 超长兜底:避免异常路径下看护线程无限轮询
|
||||||
|
let timeout = if interacted {
|
||||||
|
Duration::from_secs(600)
|
||||||
|
} else {
|
||||||
|
Duration::from_secs(30)
|
||||||
|
};
|
||||||
|
if start.elapsed() > timeout {
|
||||||
|
hide_popup(&app);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let cursor_in = is_cursor_in_popup(&app) || is_cursor_in_preview(&app);
|
||||||
|
if interacted {
|
||||||
|
// 交互锁定:左键按下沿发生在弹窗+预览之外 → 点击外部,关闭弹窗+预览。
|
||||||
|
// 按下沿判定(上一轮未按下、本轮按下)配合 30ms 轮询,可捕获常规点击;
|
||||||
|
// 在弹窗/预览内按下后拖出再松开不算外部点击(以按下位置为准)。
|
||||||
|
let down = crate::win32_util::is_left_button_down();
|
||||||
|
if down && !was_down && !cursor_in {
|
||||||
|
hide_popup(&app);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
was_down = down;
|
||||||
|
} else if cursor_in {
|
||||||
|
outside = 0;
|
||||||
|
} else {
|
||||||
|
outside += 1;
|
||||||
|
// 连续 ~300ms 不在弹窗/预览区域(排除穿越间隙的瞬时状态)才关闭
|
||||||
|
if outside >= 10 {
|
||||||
|
hide_popup(&app);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POPUP_LEAVE_WATCH.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 鼠标当前是否位于弹窗矩形内(弹窗可见且光标在其窗口范围内)。
|
||||||
|
/// 用于 hide_preview 判断"用户是回到弹窗继续浏览,还是离开整个区域"。
|
||||||
|
fn is_cursor_in_popup(app: &AppHandle) -> bool {
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(pos) = win.outer_position() else { return false };
|
||||||
|
let Ok(size) = win.outer_size() else { return false };
|
||||||
|
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let x = pos.x;
|
||||||
|
let y = pos.y;
|
||||||
|
let w = size.width as i32;
|
||||||
|
let h = size.height as i32;
|
||||||
|
cx >= x && cx <= x + w && cy >= y && cy <= y + h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 鼠标当前是否位于预览窗矩形内(预览窗可见且光标在其窗口范围内)。
|
||||||
|
/// 用于弹窗失焦时判断是否因预览交互所致,决定是否推迟隐藏弹窗。
|
||||||
|
fn is_cursor_in_preview(app: &AppHandle) -> bool {
|
||||||
|
if !PREVIEW_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(pos) = win.outer_position() else { return false };
|
||||||
|
let Ok(size) = win.outer_size() else { return false };
|
||||||
|
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let x = pos.x;
|
||||||
|
let y = pos.y;
|
||||||
|
let w = size.width as i32;
|
||||||
|
let h = size.height as i32;
|
||||||
|
cx >= x && cx <= x + w && cy >= y && cy <= y + h
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
|
// ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
|
||||||
|
|
||||||
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
|
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
|
||||||
|
|||||||
@@ -295,3 +295,29 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
|||||||
.ok()?;
|
.ok()?;
|
||||||
Some(buf)
|
Some(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成缩略图 PNG(最长边不超过 max_dim 像素)。
|
||||||
|
/// 小图直接复用 `dib_to_png` 结果;大图降采样后重新编码,供弹窗悬停预览使用。
|
||||||
|
pub fn dib_to_thumbnail(dib: &[u8], max_dim: u32) -> Option<Vec<u8>> {
|
||||||
|
use image::codecs::png::PngEncoder;
|
||||||
|
use image::GenericImageView;
|
||||||
|
use image::ImageEncoder;
|
||||||
|
|
||||||
|
let png = dib_to_png(dib)?;
|
||||||
|
let img = image::load_from_memory(&png).ok()?;
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
if w.max(h) <= max_dim {
|
||||||
|
return Some(png); // 小图直接使用,避免重复编码
|
||||||
|
}
|
||||||
|
// 让 image 库在 max_dim×max_dim 边界内自动等比缩放,避免手算 nw/nh 与库内部
|
||||||
|
// resize_dimensions 的取整不一致(如手算 256x10 而库实际输出 250x10),
|
||||||
|
// 否则 write_image 的缓冲区长度断言会失败。
|
||||||
|
let resized = img.resize(max_dim, max_dim, image::imageops::FilterType::Triangle);
|
||||||
|
let (rw, rh) = resized.dimensions();
|
||||||
|
let rgba = resized.to_rgba8();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let enc = PngEncoder::new(&mut buf);
|
||||||
|
enc.write_image(rgba.as_raw(), rw, rh, image::ExtendedColorType::Rgba8)
|
||||||
|
.ok()?;
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
|
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -38,6 +39,8 @@ pub struct NewItem {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub blob: Option<Vec<u8>>,
|
pub blob: Option<Vec<u8>>,
|
||||||
|
/// 图片缩略图 PNG(仅 image 类型,供弹窗悬停预览)
|
||||||
|
pub thumb: Option<Vec<u8>>,
|
||||||
pub preview: String,
|
pub preview: String,
|
||||||
pub size: i64,
|
pub size: i64,
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
@@ -88,6 +91,19 @@ impl Storage {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
|
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
|
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
|
||||||
);
|
);
|
||||||
|
// 迁移:老库无 thumb 列(图片缩略图,供弹窗悬停预览),补列
|
||||||
|
let has_thumb = conn
|
||||||
|
.prepare("PRAGMA table_info(clipboard_history)")
|
||||||
|
.ok()
|
||||||
|
.map(|mut stmt| {
|
||||||
|
stmt.query_map([], |r| r.get::<_, String>(1))
|
||||||
|
.map(|rows| rows.filter_map(|c| c.ok()).any(|c| c == "thumb"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_thumb {
|
||||||
|
let _ = conn.execute("ALTER TABLE clipboard_history ADD COLUMN thumb BLOB", []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
|
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
|
||||||
@@ -120,12 +136,13 @@ impl Storage {
|
|||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let res = conn.execute(
|
let res = conn.execute(
|
||||||
"INSERT INTO clipboard_history
|
"INSERT INTO clipboard_history
|
||||||
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
|
(kind, content, blob, thumb, preview, size, hash, pinned, pinned_order, created_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8)",
|
||||||
params![
|
params![
|
||||||
item.kind,
|
item.kind,
|
||||||
item.content,
|
item.content,
|
||||||
item.blob.as_deref(),
|
item.blob.as_deref(),
|
||||||
|
item.thumb.as_deref(),
|
||||||
item.preview,
|
item.preview,
|
||||||
item.size,
|
item.size,
|
||||||
item.hash,
|
item.hash,
|
||||||
@@ -249,6 +266,26 @@ impl Storage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取图片缩略图 PNG base64(弹窗悬停预览用)。
|
||||||
|
/// 老数据无缩略图时回退为 `thumb_from_dib` 现场生成。
|
||||||
|
pub fn get_thumb(&self, id: i64, thumb_from_dib: impl Fn(&[u8]) -> Option<Vec<u8>>) -> Option<String> {
|
||||||
|
let (thumb, blob) = {
|
||||||
|
let conn = self.conn.lock().ok()?;
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT thumb, blob FROM clipboard_history WHERE id = ?1 AND kind = 'image'",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
};
|
||||||
|
// conn 已在此处释放
|
||||||
|
let png = match thumb {
|
||||||
|
Some(t) if !t.is_empty() => Some(t),
|
||||||
|
_ => blob.as_deref().and_then(|b| thumb_from_dib(b)),
|
||||||
|
};
|
||||||
|
png.map(|p| base64::engine::general_purpose::STANDARD.encode(&p))
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
|
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
|
||||||
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
|
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
|
||||||
let conn = self.conn.lock().ok()?;
|
let conn = self.conn.lock().ok()?;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! 剪贴板写入抑制。
|
||||||
|
//!
|
||||||
|
//! 存在的理由:剪贴板监听线程会把**任何**序列号变化录进历史。而应用自身也会写剪贴板
|
||||||
|
//! (copy_back 写回、划词取词时模拟 Ctrl+C 与随后的还原),这些都不该出现在用户的
|
||||||
|
//! 历史里。抑制状态因此必须能被**多个模块**访问,而不是某个模块的私有字段。
|
||||||
|
//!
|
||||||
|
//! 两种机制并存,因为要解决的问题不同:
|
||||||
|
//!
|
||||||
|
//! - **精确抑制(seq)**:只跳过「本应用刚写入的那一次变化」。写入者是我们自己时,
|
||||||
|
//! 写入后的序列号可以立刻读到,于是能精确记账一次;用户随后的复制是另一个序列号,
|
||||||
|
//! 不会被误吞。`copy_back` 用这条。
|
||||||
|
//!
|
||||||
|
//! - **时间窗抑制(burst)**:屏蔽一个区间内的**所有**变化。划词取词要连续动三次剪贴板
|
||||||
|
//! (Ctrl+C 覆盖 → 我们读走 → 还原原文),而监听线程是 250ms 轮询:按序列号逐个记账
|
||||||
|
//! 存在竞态——监听恰好落在我们两次操作之间时,选区文本就被录进历史了。
|
||||||
|
//! 因此取词期间必须整体屏蔽,读完并还原之后再解除。
|
||||||
|
//!
|
||||||
|
//! burst 用**计数**而非布尔:取词流程内部可能再触发一次写入,用布尔会在内层先结束时
|
||||||
|
//! 提前解除屏蔽。配对由 [`SuppressState::burst`] 返回的 RAII 守卫保证,提前 return 也安全。
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SuppressState {
|
||||||
|
/// 待跳过的序列号(消费一次即清空)
|
||||||
|
seq: Mutex<Option<u32>>,
|
||||||
|
/// 屏蔽窗口嵌套计数
|
||||||
|
burst: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 屏蔽窗口守卫:析构时自动解除,保证与 `begin` 严格配对。
|
||||||
|
pub struct BurstGuard<'a>(&'a SuppressState);
|
||||||
|
|
||||||
|
impl Drop for BurstGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.end_burst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SuppressState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记账:跳过 `seq` 这一次变化(消费一次)。
|
||||||
|
/// 只记一个序列号即可——本应用的写入是串行的,不会同时积压多次。
|
||||||
|
pub fn mark_seq(&self, seq: u32) {
|
||||||
|
if let Ok(mut guard) = self.seq.lock() {
|
||||||
|
*guard = Some(seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始屏蔽窗口。返回的守卫析构时自动结束,**不要**手动配对 end。
|
||||||
|
pub fn burst(&self) -> BurstGuard<'_> {
|
||||||
|
self.burst.fetch_add(1, Ordering::SeqCst);
|
||||||
|
BurstGuard(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end_burst(&self) {
|
||||||
|
// saturating:异常路径下的多余 end 不应让计数下溢,否则会永久屏蔽
|
||||||
|
let _ = self
|
||||||
|
.burst
|
||||||
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
|
||||||
|
Some(v.saturating_sub(1))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前是否处于屏蔽窗口内(供调用方在取词前做提示,不参与判定)
|
||||||
|
pub fn in_burst(&self) -> bool {
|
||||||
|
self.burst.load(Ordering::SeqCst) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监听线程询问:这次变化是否应当跳过。
|
||||||
|
/// 命中序列号时**消费**该记账(下次同序列号不再跳过),避免误吞用户后续的复制。
|
||||||
|
pub fn should_skip(&self, seq: u32) -> bool {
|
||||||
|
if self.in_burst() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut guard = match self.seq.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(e) => e.into_inner(),
|
||||||
|
};
|
||||||
|
if *guard == Some(seq) {
|
||||||
|
*guard = None;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seq_suppression_is_consumed_once() {
|
||||||
|
let s = SuppressState::new();
|
||||||
|
s.mark_seq(7);
|
||||||
|
assert!(s.should_skip(7), "记账的那次应被跳过");
|
||||||
|
assert!(!s.should_skip(7), "同序列号不应被反复跳过");
|
||||||
|
assert!(!s.should_skip(8), "其他序列号不受影响");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_suppresses_everything_until_dropped() {
|
||||||
|
let s = SuppressState::new();
|
||||||
|
{
|
||||||
|
let _guard = s.burst();
|
||||||
|
assert!(s.should_skip(1));
|
||||||
|
assert!(s.should_skip(2));
|
||||||
|
assert!(s.in_burst());
|
||||||
|
}
|
||||||
|
assert!(!s.in_burst());
|
||||||
|
assert!(!s.should_skip(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_is_reentrant() {
|
||||||
|
let s = SuppressState::new();
|
||||||
|
let outer = s.burst();
|
||||||
|
{
|
||||||
|
let _inner = s.burst();
|
||||||
|
}
|
||||||
|
// 内层结束不应提前解除外层
|
||||||
|
assert!(s.in_burst());
|
||||||
|
assert!(s.should_skip(9));
|
||||||
|
drop(outer);
|
||||||
|
assert!(!s.in_burst());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_end_does_not_underflow() {
|
||||||
|
let s = SuppressState::new();
|
||||||
|
s.end_burst();
|
||||||
|
s.end_burst();
|
||||||
|
// 下溢会让计数变成极大值从而永久屏蔽,这里确保不会
|
||||||
|
assert!(!s.in_burst());
|
||||||
|
assert!(!s.should_skip(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ pub mod windows {
|
|||||||
pub const OSD_OVERLAY: &str = "osd-overlay";
|
pub const OSD_OVERLAY: &str = "osd-overlay";
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub const SCREENSHOT_OVERLAY: &str = "screenshot-overlay";
|
pub const SCREENSHOT_OVERLAY: &str = "screenshot-overlay";
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const SCREENSHOT_PIN: &str = "screenshot-pin";
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const SCREENSHOT_SCROLL: &str = "screenshot-scroll";
|
||||||
|
/// 取词翻译悬浮窗(由 translate 模块预创建,非激活显示)
|
||||||
|
pub const TRANSLATE_POPUP: &str = "translate-popup";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tauri 事件名(与前端 constants::EVENTS 对应)
|
/// Tauri 事件名(与前端 constants::EVENTS 对应)
|
||||||
@@ -24,9 +30,14 @@ pub mod events {
|
|||||||
pub const CLIPBOARD_CHANGED: &str = "clipboard-changed";
|
pub const CLIPBOARD_CHANGED: &str = "clipboard-changed";
|
||||||
pub const CLIPBOARD_POPUP_SHOW: &str = "clipboard-popup-show";
|
pub const CLIPBOARD_POPUP_SHOW: &str = "clipboard-popup-show";
|
||||||
pub const CLIPBOARD_POPUP_HIDE: &str = "clipboard-popup-hide";
|
pub const CLIPBOARD_POPUP_HIDE: &str = "clipboard-popup-hide";
|
||||||
|
pub const CLIPBOARD_PREVIEW_SHOW: &str = "clipboard-preview-show";
|
||||||
|
pub const CLIPBOARD_PREVIEW_HIDE: &str = "clipboard-preview-hide";
|
||||||
// 快速面板
|
// 快速面板
|
||||||
pub const QUICKPANEL_SHOW: &str = "quickpanel-show";
|
pub const QUICKPANEL_SHOW: &str = "quickpanel-show";
|
||||||
pub const QUICKPANEL_HIDE: &str = "quickpanel-hide";
|
pub const QUICKPANEL_HIDE: &str = "quickpanel-hide";
|
||||||
|
pub const QUICKPANEL_EXTRACT_PROGRESS: &str = "quickpanel-extract-progress";
|
||||||
|
/// 文件索引构建完成(闲时自动建立/重建、手动构建),负载为条目数
|
||||||
|
pub const QUICKPANEL_INDEX_UPDATED: &str = "quickpanel-index-updated";
|
||||||
// 监控
|
// 监控
|
||||||
pub const MONITOR_DATA: &str = "monitor-data";
|
pub const MONITOR_DATA: &str = "monitor-data";
|
||||||
pub const MONITOR_NETWORK: &str = "monitor-network";
|
pub const MONITOR_NETWORK: &str = "monitor-network";
|
||||||
@@ -37,13 +48,42 @@ pub mod events {
|
|||||||
// OSD 窗口
|
// OSD 窗口
|
||||||
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
|
pub const OSD_SYSTEM_UI_ACTIVE: &str = "osd-system-ui-active";
|
||||||
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
|
pub const OSD_SYSTEM_UI_INACTIVE: &str = "osd-system-ui-inactive";
|
||||||
|
pub const OSD_GAME_ACTIVE: &str = "osd-game-active";
|
||||||
|
pub const OSD_GAME_INACTIVE: &str = "osd-game-inactive";
|
||||||
pub const OSD_START_DRAG: &str = "osd-start-drag";
|
pub const OSD_START_DRAG: &str = "osd-start-drag";
|
||||||
pub const OSD_END_DRAG: &str = "osd-end-drag";
|
pub const OSD_END_DRAG: &str = "osd-end-drag";
|
||||||
// 截图
|
// 截图
|
||||||
pub const SCREENSHOT_SHORTCUT: &str = "screenshot-shortcut";
|
pub const SCREENSHOT_SHORTCUT: &str = "screenshot-shortcut";
|
||||||
|
pub const SCREENSHOT_PIN_SHORTCUT: &str = "screenshot-pin-shortcut";
|
||||||
|
/// 滚动截图会话:实时进度 { width, height, auto }
|
||||||
|
pub const SCROLL_PROGRESS: &str = "screenshot-scroll-progress";
|
||||||
|
/// 滚动截图会话:完成并导出
|
||||||
|
pub const SCROLL_COMPLETE: &str = "screenshot-scroll-complete";
|
||||||
|
/// 滚动截图会话:已取消(无负载)
|
||||||
|
pub const SCROLL_CANCELLED: &str = "screenshot-scroll-cancelled";
|
||||||
// 内核安装进度
|
// 内核安装进度
|
||||||
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
|
pub const KERNEL_INSTALL_PROGRESS: &str = "kernel-install-progress";
|
||||||
|
// 翻译:取词悬浮窗显示(负载见 translate::popup::PopupPayload)/ 隐藏(无负载)
|
||||||
|
pub const TRANSLATE_POPUP_SHOW: &str = "translate-popup-show";
|
||||||
|
pub const TRANSLATE_POPUP_HIDE: &str = "translate-popup-hide";
|
||||||
|
// 翻译:流式输出(负载见 translate::engines::StreamEvent;失败经 start 的 Promise reject,
|
||||||
|
// 已发出 requestId 之后的失败额外走 error 事件兜底)
|
||||||
|
pub const TRANSLATE_STREAM_CHUNK: &str = "translate-stream-chunk";
|
||||||
|
pub const TRANSLATE_STREAM_DONE: &str = "translate-stream-done";
|
||||||
|
pub const TRANSLATE_STREAM_ERROR: &str = "translate-stream-error";
|
||||||
|
// 音乐模块:Python 便携运行时安装进度
|
||||||
|
pub const MUSIC_RUNTIME_INSTALL_PROGRESS: &str = "music-runtime-install-progress";
|
||||||
|
// 音乐模块:下载任务事件(桥接事件行 → 前端,负载见 bridge.py _emit_event)
|
||||||
|
pub const MUSIC_DOWNLOAD_EVENT: &str = "music-download-event";
|
||||||
|
// 后端自动切换节点完成(前端据以刷新节点列表并提示)
|
||||||
|
pub const PROXY_AUTO_SWITCH: &str = "proxy-auto-switch";
|
||||||
|
// 应用更新进度
|
||||||
|
pub const UPDATE_PROGRESS: &str = "update-progress";
|
||||||
// 进程与下载
|
// 进程与下载
|
||||||
pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed";
|
pub const PROCESS_STATUS_CHANGED: &str = "process-status-changed";
|
||||||
pub const DOWNLOAD_ADDED: &str = "download-added";
|
pub const DOWNLOAD_ADDED: &str = "download-added";
|
||||||
|
/// 任务被删除(浏览器扩展通过 HTTP API 删除任务时发出,前端据此刷新任务列表)
|
||||||
|
pub const DOWNLOAD_REMOVED: &str = "download-removed";
|
||||||
|
/// 浏览器扩展通过 HTTP API 新增下载(负载 { id },前端据以为该任务创建专属下载窗口)
|
||||||
|
pub const DOWNLOAD_EXTENSION_ADDED: &str = "download-extension-added";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use super::engine::{CheckUrlResult, DownloadEngine};
|
use super::engine::{CheckUrlResult, DownloadEngine};
|
||||||
use super::task::{DownloadTask, DownloaderSettings};
|
use super::task::{DownloadTask, DownloaderSettings};
|
||||||
|
use super::torrent::TorrentInfo;
|
||||||
|
|
||||||
/// 获取所有任务
|
/// 获取所有任务
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -13,6 +14,20 @@ pub fn downloader_get_tasks(engine: State<'_, DownloadEngine>) -> Vec<DownloadTa
|
|||||||
engine.get_tasks()
|
engine.get_tasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析磁力链 / .torrent 文件,返回种子信息(名称 / infohash / 文件列表),供前端做文件勾选
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn downloader_inspect(engine: State<'_, DownloadEngine>, input: String) -> Result<TorrentInfo, String> {
|
||||||
|
engine.inspect(&input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 磁力任务:元数据解析成功后,用户勾选文件并确认开始下载
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn downloader_select_bt_files(engine: State<'_, DownloadEngine>, id: String, only_files: Vec<u32>) -> Result<(), String> {
|
||||||
|
engine.select_bt_files(&id, only_files).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
|
/// 检查 URL 重复性并探测文件信息(添加下载前调用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -57,8 +72,9 @@ pub async fn downloader_add_task(
|
|||||||
dir: Option<String>,
|
dir: Option<String>,
|
||||||
headers: Option<HashMap<String, String>>,
|
headers: Option<HashMap<String, String>>,
|
||||||
auto_rename: Option<bool>,
|
auto_rename: Option<bool>,
|
||||||
|
only_files: Option<Vec<u32>>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false)).await
|
engine.add_task(url, filename, dir, headers.unwrap_or_default(), auto_rename.unwrap_or(false), only_files).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 暂停任务
|
/// 暂停任务
|
||||||
@@ -75,6 +91,20 @@ pub fn downloader_resume_task(engine: State<'_, DownloadEngine>, id: String) ->
|
|||||||
engine.resume_task(&id)
|
engine.resume_task(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取消任务(置为已取消,清空进度并删除下载文件,但保留记录)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn downloader_cancel_task(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||||
|
engine.cancel_task(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新下载已取消/出错的任务
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn downloader_redownload(engine: State<'_, DownloadEngine>, id: String) -> Result<(), String> {
|
||||||
|
engine.redownload(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 移除任务
|
/// 移除任务
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -141,3 +171,24 @@ pub fn downloader_open_url(app: AppHandle, url: String) -> Result<(), String> {
|
|||||||
.open_url(url, None::<&str>)
|
.open_url(url, None::<&str>)
|
||||||
.map_err(|e| format!("打开链接失败: {}", e))
|
.map_err(|e| format!("打开链接失败: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将指定 label 的下载窗口显示并强制置为前台。
|
||||||
|
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制(尤其下载窗口由后台进程创建、
|
||||||
|
/// 或创建到非主显示器时更明显),改用原生 SetForegroundWindow + BringWindowToTop
|
||||||
|
/// (模拟 Alt 键重置前台锁定),保证开始/完成下载时窗口能正确定位到前台。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn downloader_focus_window(app: AppHandle, label: String) -> Result<(), String> {
|
||||||
|
let Some(window) = app.get_webview_window(&label) else {
|
||||||
|
return Ok(()); // 窗口已关闭则忽略
|
||||||
|
};
|
||||||
|
window.show().map_err(|e| e.to_string())?;
|
||||||
|
window.unminimize().map_err(|e| e.to_string())?;
|
||||||
|
match window.hwnd() {
|
||||||
|
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
|
||||||
|
Err(_) => {
|
||||||
|
window.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use reqwest::Proxy;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::SeekFrom;
|
use std::io::SeekFrom;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
@@ -21,27 +22,110 @@ const READ_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3
|
|||||||
/// HTTP/HTTPS 下载器
|
/// HTTP/HTTPS 下载器
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HttpDownloader {
|
pub struct HttpDownloader {
|
||||||
client: Client,
|
/// 直连客户端:强制禁用代理(no_proxy),用于降级重试与控制端探测
|
||||||
|
direct_client: Client,
|
||||||
|
/// mihomo 显式代理客户端缓存(与代理地址一一对应):走 http://127.0.0.1:{mixed_port}。
|
||||||
|
/// 地址未配置(mihomo 未运行)时为 no_proxy 直连。Arc 共享同一状态,任意 clone 统一生效。
|
||||||
|
mihomo: Arc<Mutex<(Option<String>, Client)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpDownloader {
|
impl HttpDownloader {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let client = Client::builder()
|
let direct_client = Client::builder()
|
||||||
|
// 强制直连:即使系统代理已开启,下载也不经过系统代理
|
||||||
|
.no_proxy()
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new());
|
.unwrap_or_else(|_| Client::new());
|
||||||
Self { client }
|
Self {
|
||||||
|
direct_client: direct_client.clone(),
|
||||||
|
mihomo: Arc::new(Mutex::new((None, direct_client))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置 mihomo 显式代理客户端。
|
||||||
|
/// - `Some("http://127.0.0.1:{port}")`:走本地 mihomo 的 mixed 端口;
|
||||||
|
/// - `None`:回退直连(不走系统代理)。
|
||||||
|
/// 地址未变化时复用缓存,不重复重建,避免丢失连接复用。
|
||||||
|
pub fn configure_mihomo_proxy(&self, proxy_url: Option<String>) {
|
||||||
|
let mut guard = self.mihomo.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if guard.0 == proxy_url {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let client = match &proxy_url {
|
||||||
|
Some(url) => {
|
||||||
|
let mut cb = Client::builder();
|
||||||
|
if let Ok(p) = Proxy::all(url.clone()) {
|
||||||
|
cb = cb.proxy(p);
|
||||||
|
}
|
||||||
|
cb.build().unwrap_or_else(|_| Client::new())
|
||||||
|
}
|
||||||
|
None => Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| Client::new()),
|
||||||
|
};
|
||||||
|
*guard = (proxy_url, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测 mihomo 外部控制端是否在线(用于判断代理模块是否真正运行)。
|
||||||
|
/// 走直连客户端,避免探测本身依赖代理。
|
||||||
|
pub async fn probe_controller(&self, controller: &str) -> bool {
|
||||||
|
let url = format!("http://{}/version", controller);
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
self.direct_client.get(&url).send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(r)) => r.status().is_success(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 use_proxy 选择客户端(克隆句柄,Client 内部共享连接池)
|
||||||
|
fn client(&self, use_proxy: bool) -> Client {
|
||||||
|
if use_proxy {
|
||||||
|
self.mihomo
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.1
|
||||||
|
.clone()
|
||||||
|
} else {
|
||||||
|
self.direct_client.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 探测下载资源信息(大小、是否支持 Range、文件名)
|
/// 探测下载资源信息(大小、是否支持 Range、文件名)
|
||||||
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD
|
/// 优先用 GET + Range: bytes=0-0(返回 206 + Content-Range),回退到 HEAD。
|
||||||
|
/// 代理降级:use_proxy=true 时先走系统代理,失败则回退 no_proxy 直连重试一次
|
||||||
pub async fn probe(
|
pub async fn probe(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
headers: &HashMap<String, String>,
|
headers: &HashMap<String, String>,
|
||||||
|
use_proxy: bool,
|
||||||
|
) -> Result<ProbeResult, String> {
|
||||||
|
let first = self.client(use_proxy);
|
||||||
|
match self.probe_with_client(&first, url, headers).await {
|
||||||
|
Ok(r) => return Ok(r),
|
||||||
|
Err(e) if use_proxy => {
|
||||||
|
let direct = self.client(false);
|
||||||
|
self.probe_with_client(&direct, url, headers)
|
||||||
|
.await
|
||||||
|
.map_err(|e2| format!("代理探测失败({}),直连重试也失败({})", e, e2))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用指定 client 执行探测(GET Range → 回退 HEAD),供代理降级复用
|
||||||
|
async fn probe_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
headers: &HashMap<String, String>,
|
||||||
) -> Result<ProbeResult, String> {
|
) -> Result<ProbeResult, String> {
|
||||||
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
|
// 先尝试 Range 请求(能同时判断 Accept-Ranges 和获取大小)
|
||||||
let mut req = self
|
let mut req = client
|
||||||
.client
|
|
||||||
.get(url)
|
.get(url)
|
||||||
.header("Range", "bytes=0-0")
|
.header("Range", "bytes=0-0")
|
||||||
.header("User-Agent", "Thing-Download-Engine/1.0");
|
.header("User-Agent", "Thing-Download-Engine/1.0");
|
||||||
@@ -97,7 +181,7 @@ impl HttpDownloader {
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// GET 失败,尝试 HEAD 作为回退
|
// GET 失败,尝试 HEAD 作为回退
|
||||||
let mut head_req = self.client.head(url);
|
let mut head_req = client.head(url);
|
||||||
for (k, v) in headers {
|
for (k, v) in headers {
|
||||||
head_req = head_req.header(k, v);
|
head_req = head_req.header(k, v);
|
||||||
}
|
}
|
||||||
@@ -132,6 +216,7 @@ impl HttpDownloader {
|
|||||||
/// - `cancel`: 取消标志
|
/// - `cancel`: 取消标志
|
||||||
/// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应)
|
/// - `progress`: 每个分段的已下载字节(AtomicU64,与 segments 一一对应)
|
||||||
/// - `limiter`: 全局限速器
|
/// - `limiter`: 全局限速器
|
||||||
|
/// - `use_proxy`: 是否使用系统代理(false=强制直连)
|
||||||
pub async fn download(
|
pub async fn download(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -141,6 +226,37 @@ impl HttpDownloader {
|
|||||||
cancel: Arc<AtomicBool>,
|
cancel: Arc<AtomicBool>,
|
||||||
progress: &[Arc<AtomicU64>],
|
progress: &[Arc<AtomicU64>],
|
||||||
limiter: Arc<RateLimiter>,
|
limiter: Arc<RateLimiter>,
|
||||||
|
use_proxy: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// 代理降级:仅当 use_proxy=true 才有"走代理→失败回退直连"的意义。
|
||||||
|
// use_proxy=false 直接用直连客户端,无需回退。
|
||||||
|
// 注意:用户主动暂停/取消(返回"已取消")必须原样透传,不能触发代理回退,
|
||||||
|
// 否则会把"已取消"包装成"代理失败",导致引擎将其误判为错误而非暂停。
|
||||||
|
let first = self.client(use_proxy);
|
||||||
|
match self.download_with_client(&first, url, headers, segments, file_path, cancel.clone(), progress, &limiter).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) if use_proxy && e != "已取消" => {
|
||||||
|
// 回退直连重试(不继承 use_proxy,保证用 no_proxy 客户端)
|
||||||
|
let direct = self.client(false);
|
||||||
|
self.download_with_client(&direct, url, headers, segments, file_path, cancel, progress, &limiter)
|
||||||
|
.await
|
||||||
|
.map_err(|e2| format!("代理下载失败({}),直连重试也失败({})", e, e2))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用指定 client 执行下载(支持单线程与多线程分段),供代理降级复用
|
||||||
|
async fn download_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
url: &str,
|
||||||
|
headers: &HashMap<String, String>,
|
||||||
|
segments: &[Segment],
|
||||||
|
file_path: &Path,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
progress: &[Arc<AtomicU64>],
|
||||||
|
limiter: &Arc<RateLimiter>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let total_size = segments.iter().map(|s| s.len()).sum();
|
let total_size = segments.iter().map(|s| s.len()).sum();
|
||||||
|
|
||||||
@@ -167,7 +283,7 @@ impl HttpDownloader {
|
|||||||
// 单线程下载(不支持 Range 或文件太小)
|
// 单线程下载(不支持 Range 或文件太小)
|
||||||
let seg = &segments[0];
|
let seg = &segments[0];
|
||||||
let prog = &progress[0];
|
let prog = &progress[0];
|
||||||
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter)
|
self.download_segment(url, headers, seg, file_path, cancel.clone(), prog.clone(), limiter.clone(), client)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -187,7 +303,7 @@ impl HttpDownloader {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let limiter = limiter.clone();
|
let limiter = limiter.clone();
|
||||||
let file_path = file_path.to_path_buf();
|
let file_path = file_path.to_path_buf();
|
||||||
let client = self.client.clone();
|
let client = client.clone();
|
||||||
|
|
||||||
join_set.spawn(async move {
|
join_set.spawn(async move {
|
||||||
download_segment_with_client(
|
download_segment_with_client(
|
||||||
@@ -241,9 +357,10 @@ impl HttpDownloader {
|
|||||||
cancel: Arc<AtomicBool>,
|
cancel: Arc<AtomicBool>,
|
||||||
progress: Arc<AtomicU64>,
|
progress: Arc<AtomicU64>,
|
||||||
limiter: Arc<RateLimiter>,
|
limiter: Arc<RateLimiter>,
|
||||||
|
client: &Client,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
download_segment_with_client(
|
download_segment_with_client(
|
||||||
&self.client,
|
client,
|
||||||
url,
|
url,
|
||||||
headers,
|
headers,
|
||||||
seg,
|
seg,
|
||||||
@@ -366,6 +483,15 @@ async fn download_segment_with_client(
|
|||||||
limiter.consume(buf.len() as u64).await;
|
limiter.consume(buf.len() as u64).await;
|
||||||
buf.clear();
|
buf.clear();
|
||||||
}
|
}
|
||||||
|
// 校验:已知大小的分段若流提前结束(收到的字节数不足分段长度),
|
||||||
|
// 说明服务器提前断开或返回不完整内容,不能标记为完成,否则文件会被截断
|
||||||
|
if !unknown_size && local_completed < seg.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"文件不完整:已接收 {} / {} 字节,服务器提前结束连接",
|
||||||
|
local_completed,
|
||||||
|
seg.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ pub mod rate_limit;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
pub mod torrent;
|
||||||
|
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
|
||||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
|
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
|
||||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||||
};
|
};
|
||||||
pub use engine::DownloadEngine;
|
pub use engine::DownloadEngine;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -9,13 +11,25 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
use super::engine::DownloadEngine;
|
use super::engine::DownloadEngine;
|
||||||
use super::task::DownloadTask;
|
use super::task::{DownloadTask, TaskStatus};
|
||||||
|
|
||||||
/// 扩展 HTTP API 服务器
|
/// 扩展 HTTP API 服务器
|
||||||
pub struct ExtensionServer;
|
pub struct ExtensionServer;
|
||||||
|
|
||||||
|
/// 最近处理过的下载 URL(短窗口去重):记录对应任务 id 与创建时刻,
|
||||||
|
/// 用于拦截"探测期间并发 POST"与"任务已结束但扩展重试"造成的重复创建
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RecentEntry {
|
||||||
|
id: String,
|
||||||
|
at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 URL 去重窗口:窗口内重复 POST 复用既有任务 id
|
||||||
|
const DEDUP_WINDOW: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct HealthResponse {
|
struct HealthResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -45,14 +59,19 @@ struct ErrorResponse {
|
|||||||
|
|
||||||
impl ExtensionServer {
|
impl ExtensionServer {
|
||||||
/// 启动 HTTP API 服务器(绑定到 127.0.0.1:port)
|
/// 启动 HTTP API 服务器(绑定到 127.0.0.1:port)
|
||||||
pub async fn start(engine: DownloadEngine, port: u16, secret: String) {
|
pub async fn start(engine: DownloadEngine, port: u16, secret: String, app_handle: AppHandle) {
|
||||||
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("无效端口");
|
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("无效端口");
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/api/downloads", post(create_download).get(list_downloads))
|
.route("/api/downloads", post(create_download).get(list_downloads))
|
||||||
.route("/api/downloads/:id", axum::routing::delete(remove_download))
|
.route("/api/downloads/:id", axum::routing::delete(remove_download))
|
||||||
.with_state(AppState { engine, secret });
|
.with_state(AppState {
|
||||||
|
engine,
|
||||||
|
secret,
|
||||||
|
app_handle,
|
||||||
|
recent: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
});
|
||||||
|
|
||||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -74,6 +93,9 @@ impl ExtensionServer {
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
engine: DownloadEngine,
|
engine: DownloadEngine,
|
||||||
secret: String,
|
secret: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
/// 最近创建的 URL→任务 id(短窗口去重)
|
||||||
|
recent: Arc<Mutex<HashMap<String, RecentEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 鉴权检查:如果配置了 secret,校验 Bearer token
|
/// 鉴权检查:如果配置了 secret,校验 Bearer token
|
||||||
@@ -108,8 +130,48 @@ async fn create_download(
|
|||||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||||
}
|
}
|
||||||
|
|
||||||
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true).await {
|
// 1. 检查短窗口缓存:同 URL 30s 内已有创建记录,直接返回(拦截浏览器/扩展重试和并发 POST)
|
||||||
Ok(id) => Ok(Json(CreateDownloadResponse { id })),
|
{
|
||||||
|
let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(entry) = recent.get(&req.url) {
|
||||||
|
if entry.at.elapsed() < DEDUP_WINDOW {
|
||||||
|
// 30 秒内重复请求 → 返回已创建任务 id,不重复新建
|
||||||
|
return Ok(Json(CreateDownloadResponse { id: entry.id.clone() }));
|
||||||
|
} else {
|
||||||
|
// 窗口过期 → 删除旧记录继续检查引擎层去重
|
||||||
|
recent.remove(&req.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 引擎层去重:同 URL 已有非终态任务(活跃/排队/暂停)时直接返回既有任务,
|
||||||
|
// 避免浏览器重复转发同一下载造成重复下载
|
||||||
|
if let Some(existing) = state.engine.get_tasks().into_iter().find(|t| {
|
||||||
|
matches!(t.status, TaskStatus::Active | TaskStatus::Queued | TaskStatus::Paused) && t.url == req.url
|
||||||
|
}) {
|
||||||
|
// 添加到短窗口缓存以便拦截重试
|
||||||
|
let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
recent.insert(req.url.clone(), RecentEntry { id: existing.id.clone(), at: Instant::now() });
|
||||||
|
return Ok(Json(CreateDownloadResponse { id: existing.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_task 会 move 掉 req 的字段,先取出 url 供去重缓存使用
|
||||||
|
let task_url = req.url.clone();
|
||||||
|
match state.engine.add_task(req.url, req.filename, req.dir, req.headers, true, None).await {
|
||||||
|
Ok(id) => {
|
||||||
|
// 记录到短窗口缓存:拦截后续同一 URL 的重复转发/重试
|
||||||
|
let url = task_url.clone();
|
||||||
|
let mut recent = state.recent.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
recent.insert(url, RecentEntry { id: id.clone(), at: Instant::now() });
|
||||||
|
drop(recent);
|
||||||
|
// 浏览器扩展发起下载:不再置前主窗口,改为带 task id 通知前端,
|
||||||
|
// 由前端为该任务创建一个专属的一次性下载窗口(不打断主界面)
|
||||||
|
let _ = state.app_handle.emit(
|
||||||
|
crate::constants::events::DOWNLOAD_EXTENSION_ADDED,
|
||||||
|
serde_json::json!({ "id": id }),
|
||||||
|
);
|
||||||
|
Ok(Json(CreateDownloadResponse { id }))
|
||||||
|
}
|
||||||
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
|
Err(e) => Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e }))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +195,11 @@ async fn remove_download(
|
|||||||
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
return Err((StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "未授权".into() })));
|
||||||
}
|
}
|
||||||
match state.engine.remove_task(&id, false) {
|
match state.engine.remove_task(&id, false) {
|
||||||
Ok(()) => Ok(StatusCode::NO_CONTENT),
|
Ok(()) => {
|
||||||
|
// 通知前端刷新任务列表(扩展删除时前端无从感知,否则列表残留已删除任务)
|
||||||
|
let _ = state.app_handle.emit(crate::constants::events::DOWNLOAD_REMOVED, serde_json::json!({ "id": id }));
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))),
|
Err(e) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ use serde::{Deserialize, Serialize};
|
|||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// 任务下载协议类型
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TaskProtocol {
|
||||||
|
/// HTTP/HTTPS 直链
|
||||||
|
#[default]
|
||||||
|
Http,
|
||||||
|
/// BitTorrent(磁力链 / .torrent 文件)
|
||||||
|
BitTorrent,
|
||||||
|
}
|
||||||
|
|
||||||
/// 任务状态
|
/// 任务状态
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -16,6 +27,8 @@ pub enum TaskStatus {
|
|||||||
Complete,
|
Complete,
|
||||||
/// 错误
|
/// 错误
|
||||||
Error,
|
Error,
|
||||||
|
/// 已取消(用户取消:进度与文件已清除,仅保留记录,只能再次下载)
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载分段(多线程 Range 下载 / 断点续传用)
|
/// 下载分段(多线程 Range 下载 / 断点续传用)
|
||||||
@@ -37,24 +50,57 @@ impl Segment {
|
|||||||
pub fn len(&self) -> u64 {
|
pub fn len(&self) -> u64 {
|
||||||
self.end.saturating_sub(self.start) + 1
|
self.end.saturating_sub(self.start) + 1
|
||||||
}
|
}
|
||||||
|
/// 是否为未知大小哨兵段(start=0, end=0,表示不支持 Range 或未探测到大小)
|
||||||
|
pub fn is_unknown_size(&self) -> bool {
|
||||||
|
self.start == 0 && self.end == 0
|
||||||
|
}
|
||||||
/// 是否已完成
|
/// 是否已完成
|
||||||
pub fn is_done(&self) -> bool {
|
pub fn is_done(&self) -> bool {
|
||||||
|
// 未知大小段无法用长度判断是否完成,由流结束(Ok(None))判定;
|
||||||
|
// 若按 len()=1 判断,暂停/恢复后 completed>=1 会误判为已完成,导致文件被截断
|
||||||
|
if self.is_unknown_size() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
self.completed >= self.len()
|
self.completed >= self.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BT 种子内文件条目(多文件任务用;阶段1下载全部文件,但保留列表供 UI 展示)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BtFileInfo {
|
||||||
|
/// 文件在种子内的索引
|
||||||
|
pub index: u32,
|
||||||
|
/// 相对种子根目录的路径(如 "sub/file.mkv")
|
||||||
|
pub path: String,
|
||||||
|
/// 文件大小(字节)
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// 下载任务
|
/// 下载任务
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DownloadTask {
|
pub struct DownloadTask {
|
||||||
/// 任务 ID(自增 hex 字符串)
|
/// 任务 ID(自增 hex 字符串)
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// 下载地址
|
/// 下载地址(HTTP URL 或磁力链接)
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// 文件名
|
/// 文件名(HTTP:目标文件名;BT:种子名称)
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
/// 保存目录(绝对路径)
|
/// 保存目录(绝对路径)
|
||||||
pub dir: String,
|
pub dir: String,
|
||||||
|
/// 协议类型
|
||||||
|
#[serde(default)]
|
||||||
|
pub protocol: TaskProtocol,
|
||||||
|
/// BT 种子 infohash(协议=BitTorrent 时存在)
|
||||||
|
#[serde(default)]
|
||||||
|
pub info_hash: Option<String>,
|
||||||
|
/// BT 种子内文件列表(协议=BitTorrent 时存在)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_files: Vec<BtFileInfo>,
|
||||||
|
/// BT 元数据是否已解析就绪(异步添加时:后台解析完成前为 false,调度器跳过)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_metadata_ready: bool,
|
||||||
/// 状态
|
/// 状态
|
||||||
pub status: TaskStatus,
|
pub status: TaskStatus,
|
||||||
/// 文件总大小(字节),0=未知
|
/// 文件总大小(字节),0=未知
|
||||||
@@ -129,6 +175,21 @@ pub struct DownloaderSettings {
|
|||||||
/// 添加下载前检查重复(URL 或文件名重复时询问)
|
/// 添加下载前检查重复(URL 或文件名重复时询问)
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub check_duplicate: bool,
|
pub check_duplicate: bool,
|
||||||
|
/// 下载是否使用代理:true=尊重系统代理(mihomo 开启系统代理时经其转发),false=强制直连
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub use_proxy: bool,
|
||||||
|
/// BitTorrent 上传限速 KB/s(0=不限)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_upload_limit_kb: u64,
|
||||||
|
/// BitTorrent 下载完成后是否继续做种上传(false=下载完即停止上传)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_seed_after_download: bool,
|
||||||
|
/// BitTorrent 监听端口(0=自动选择)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_listen_port: u16,
|
||||||
|
/// BitTorrent 使用代理下载:开启后自动使用代理模块(mihomo)的 SOCKS5 端口;代理不可用时降级直连
|
||||||
|
#[serde(default)]
|
||||||
|
pub bt_use_proxy: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_concurrent() -> u32 {
|
fn default_max_concurrent() -> u32 {
|
||||||
@@ -167,6 +228,11 @@ impl Default for DownloaderSettings {
|
|||||||
extension_secret: String::new(),
|
extension_secret: String::new(),
|
||||||
delete_files_on_remove: false,
|
delete_files_on_remove: false,
|
||||||
check_duplicate: true,
|
check_duplicate: true,
|
||||||
|
use_proxy: true,
|
||||||
|
bt_upload_limit_kb: 0,
|
||||||
|
bt_seed_after_download: false,
|
||||||
|
bt_listen_port: 0,
|
||||||
|
bt_use_proxy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
|
// 会话字段专用 tokio 异步互斥(与 std 互斥区分开):
|
||||||
|
// 创建 Session 是 async 操作,必须持锁跨 await,否则"双重检查"在 await 期间失效,
|
||||||
|
// 并发首次添加 BT 任务可能各建一个全局会话,产生孤儿会话与跨会话无效句柄。
|
||||||
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
|
||||||
|
use librqbit::api::TorrentIdOrHash;
|
||||||
|
use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session};
|
||||||
|
|
||||||
|
use super::task::BtFileInfo;
|
||||||
|
|
||||||
|
/// 磁力元数据解析超时(秒):依赖 DHT/tracker 拉取,过短易误报,过长体验差
|
||||||
|
pub(crate) const INSPECT_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
|
/// 每日 tracker 同步源(XIU2/TrackersListCollection,官方新域名 cf.trackerslist.com)。
|
||||||
|
/// 注意:GitHub 仓库 raw 路径(raw.githubusercontent.com/...trackers_best.txt)已随项目
|
||||||
|
/// 迁移失效(404),不要再用 GitHub 代理镜像。以下为官方 Cloudflare 分发地址,
|
||||||
|
/// 国内直连通常可达,按顺序尝试、首个成功即用。
|
||||||
|
const TRACKER_SYNC_SOURCES: &[&str] = &[
|
||||||
|
"https://cf.trackerslist.com/best.txt",
|
||||||
|
"https://trackerslist.com/best.txt",
|
||||||
|
"https://cf.trackerslist.com/all.txt",
|
||||||
|
];
|
||||||
|
/// 动态 tracker 持久化文件与同步元数据
|
||||||
|
const TRACKERS_FILE: &str = "bt_trackers.txt";
|
||||||
|
const TRACKERS_META_FILE: &str = "bt_trackers_meta.json";
|
||||||
|
|
||||||
|
/// 常用公共 tracker:磁力链接本身可能只带很少 tracker,追加这些可提升解析成功率。
|
||||||
|
/// 混合 UDP/HTTP(S)/WebSocket,覆盖 UDP 被屏蔽但 HTTP 可用的网络环境。
|
||||||
|
pub const PUBLIC_TRACKERS: &[&str] = &[
|
||||||
|
// UDP
|
||||||
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
|
"udp://open.tracker.cl:1337/announce",
|
||||||
|
"udp://tracker.openbittorrent.com:6969/announce",
|
||||||
|
"udp://tracker.torrent.eu.org:451/announce",
|
||||||
|
"udp://open.stealth.si:80/announce",
|
||||||
|
"udp://exodus.desync.com:6969/announce",
|
||||||
|
"udp://tracker.tiny-vps.com:6969/announce",
|
||||||
|
"udp://open.demonii.com:1337/announce",
|
||||||
|
"udp://tracker.moeking.me:6969/announce",
|
||||||
|
"udp://ipv4.tracker.harry.lu:80/announce",
|
||||||
|
"udp://explodie.org:6969/announce",
|
||||||
|
"udp://tracker.birkenfeld.ru:7496/announce",
|
||||||
|
"udp://tracker.pomf.se:80/announce",
|
||||||
|
"udp://tracker.tamersunion.org:6969/announce",
|
||||||
|
"udp://retracker.lanta-net.ru:2710/announce",
|
||||||
|
// HTTP(S)
|
||||||
|
"http://tracker.opentrackr.org:1337/announce",
|
||||||
|
"http://tracker.openbittorrent.com:80/announce",
|
||||||
|
"http://tracker1.itzmx.com:8080/announce",
|
||||||
|
"http://tracker4.itzmx.com:2710/announce",
|
||||||
|
"https://tracker.gbitt.info:443/announce",
|
||||||
|
"https://tracker.nanoha.org:443/announce",
|
||||||
|
"http://tracker.bt4g.com:2095/announce",
|
||||||
|
"http://tracker.gbitt.info:80/announce",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 种子信息(inspect 解析结果,供命令返回给前端做文件勾选)
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TorrentInfo {
|
||||||
|
/// 种子名称
|
||||||
|
pub name: String,
|
||||||
|
/// infohash(hex 小写字符串)
|
||||||
|
pub info_hash: String,
|
||||||
|
/// 种子内全部文件总大小(字节)
|
||||||
|
pub total_size: u64,
|
||||||
|
/// 种子内文件列表
|
||||||
|
pub files: Vec<BtFileInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BitTorrent 下载器:封装 librqbit 全局会话(多任务共享 DHT / 监听端口 / tracker 缓存)。
|
||||||
|
/// 会话懒创建:只有真正添加 BT 任务时才初始化,避免引擎启动即拉起 BT 内核。
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TorrentDownloader {
|
||||||
|
/// 全局会话(懒创建;tokio 异步互斥保证并发首次添加只创建一个会话)
|
||||||
|
session: Arc<AsyncMutex<Option<Arc<Session>>>>,
|
||||||
|
/// 会话默认输出目录(每个任务用 AddTorrentOptions.output_folder 覆盖)
|
||||||
|
base_dir: PathBuf,
|
||||||
|
/// 上传限速 bytes/s(0=不限),per-torrent 应用
|
||||||
|
upload_limit_bps: Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
/// 监听端口(0=自动)
|
||||||
|
listen_port: Arc<std::sync::atomic::AtomicU16>,
|
||||||
|
/// SOCKS5 代理地址(None=直连;来自代理模块,不可用时降级直连)
|
||||||
|
proxy_addr: Arc<Mutex<Option<String>>>,
|
||||||
|
/// 动态公共 tracker(每日从外部列表同步,叠加到内置列表)
|
||||||
|
dynamic_trackers: Arc<RwLock<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentDownloader {
|
||||||
|
pub fn new(base_dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
session: Arc::new(AsyncMutex::new(None)),
|
||||||
|
base_dir,
|
||||||
|
upload_limit_bps: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||||
|
listen_port: Arc::new(std::sync::atomic::AtomicU16::new(0)),
|
||||||
|
proxy_addr: Arc::new(Mutex::new(None)),
|
||||||
|
dynamic_trackers: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新 BT 专属设置(上传限速 KB/s、监听端口、SOCKS5 代理地址)— 会话创建时生效
|
||||||
|
pub fn set_settings(&self, upload_limit_kb: u64, listen_port: u16, proxy_addr: Option<String>) {
|
||||||
|
self.upload_limit_bps
|
||||||
|
.store(upload_limit_kb.saturating_mul(1024), std::sync::atomic::Ordering::SeqCst);
|
||||||
|
self.listen_port
|
||||||
|
.store(listen_port, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
*self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()) = proxy_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置动态 tracker(合并去重)
|
||||||
|
pub fn add_dynamic_trackers(&self, list: Vec<String>) {
|
||||||
|
if list.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut g = self.dynamic_trackers.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
for t in list {
|
||||||
|
let t = t.trim().to_string();
|
||||||
|
if !t.is_empty() && !g.contains(&t) {
|
||||||
|
g.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前动态 tracker 数量
|
||||||
|
pub fn dynamic_count(&self) -> usize {
|
||||||
|
self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并内置 + 动态 tracker(去重),供 inspect / add_async 共用
|
||||||
|
fn build_trackers(&self) -> Vec<String> {
|
||||||
|
let dyn_trackers = self.dynamic_trackers.read().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
let mut trackers: Vec<String> = PUBLIC_TRACKERS.iter().map(|s| s.to_string()).collect();
|
||||||
|
for t in dyn_trackers {
|
||||||
|
if !trackers.contains(&t) {
|
||||||
|
trackers.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每日同步动态 tracker:读取本地缓存 → 判断今天是否已同步 → 未同步则拉取更新。
|
||||||
|
/// 失败静默降级:保留上次成功的列表。
|
||||||
|
pub async fn sync_dynamic_trackers(&self, data_dir: &Path) {
|
||||||
|
fn parse_list(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.filter(|l| l.starts_with("http://") || l.starts_with("https://") || l.starts_with("udp://") || l.starts_with("ws://") || l.starts_with("wss://"))
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
let trackers_path = data_dir.join(TRACKERS_FILE);
|
||||||
|
let meta_path = data_dir.join(TRACKERS_META_FILE);
|
||||||
|
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
// 1. 先加载上次成功保存的列表到内存(覆盖"重启后内存被清空")
|
||||||
|
if let Ok(s) = std::fs::read_to_string(&trackers_path) {
|
||||||
|
self.add_dynamic_trackers(parse_list(&s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 今天已同步过则不再下载
|
||||||
|
if let Ok(meta) = std::fs::read_to_string(&meta_path) {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta) {
|
||||||
|
if v.get("date").and_then(|d| d.as_str()) == Some(&today) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 依次尝试各镜像源(记录每个源的失败原因,便于判断是 DNS 还是连接超时)
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.ok();
|
||||||
|
for url in TRACKER_SYNC_SOURCES {
|
||||||
|
let Some(c) = &client else { break };
|
||||||
|
match c.get(*url).send().await {
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源不可达 {}: {}", url, e));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
let text = match resp.text().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源读取失败 {}: {}", url, e));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let list = parse_list(&text);
|
||||||
|
if list.is_empty() {
|
||||||
|
crate::logger::log_line("download", crate::logger::LogLevel::Debug, &format!("动态 tracker 源返回空列表 {}", url));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 保存列表与同步元数据
|
||||||
|
let _ = std::fs::write(&trackers_path, format!("{}\n", list.join("\n")));
|
||||||
|
let _ = std::fs::write(&meta_path, serde_json::json!({ "date": today }).to_string());
|
||||||
|
self.add_dynamic_trackers(list);
|
||||||
|
crate::logger::log_info("download", &format!("已同步动态 tracker(共 {} 条)", self.dynamic_count()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::logger::log_warn("download", "动态 tracker 全部同步源不可达,使用上次成功列表或内置列表(不影响磁力解析,公共 tracker 仍会生效)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话级公共 tracker(HashSet<Url>,供 SessionOptions.trackers 使用)。
|
||||||
|
/// 注意:librqbit 对磁力链接只采用 magnet URL 自带的 tr= 参数,完全忽略
|
||||||
|
/// AddTorrentOptions.trackers(见 librqbit session.rs 的 magnet 分支),导致
|
||||||
|
/// 纯磁力(无自带 tr)解析元数据时 "trackers list is empty"。而会话级
|
||||||
|
/// SessionOptions.trackers 会在 make_peer_rx 中合并进每个种子(含磁力),
|
||||||
|
/// 因此必须放到这里才能让磁力链接真正带上公共 tracker。
|
||||||
|
fn session_trackers(&self) -> std::collections::HashSet<url::Url> {
|
||||||
|
self.build_trackers()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|t| url::Url::parse(&t).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取或创建全局会话。
|
||||||
|
/// 持 tokio 互斥锁覆盖整个构造过程:并发调用在此串行化,
|
||||||
|
/// 先进入者创建并写入,后续者锁内二次检查直接复用(避免多会话竞态)。
|
||||||
|
/// 开启代理时先尝试用代理初始化;失败则降级为直连(记录下来供 add 重试判断)。
|
||||||
|
async fn get_session(&self) -> Result<Arc<Session>, String> {
|
||||||
|
let mut guard = self.session.lock().await;
|
||||||
|
if let Some(s) = guard.as_ref() {
|
||||||
|
return Ok(s.clone());
|
||||||
|
}
|
||||||
|
let _ = std::fs::create_dir_all(&self.base_dir);
|
||||||
|
let port = self.listen_port.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let proxy = self.proxy_addr.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
let connect_opt = proxy.as_ref().map(|p| librqbit::ConnectionOptions { proxy_url: Some(p.clone()), ..Default::default() });
|
||||||
|
let had_proxy = connect_opt.is_some();
|
||||||
|
|
||||||
|
// 构造带代理(若启用)+ 监听端口 + 会话级公共 tracker 的会话选项
|
||||||
|
let mut opts = lib_session_options();
|
||||||
|
opts.trackers = self.session_trackers();
|
||||||
|
if connect_opt.is_some() {
|
||||||
|
opts.connect = connect_opt;
|
||||||
|
}
|
||||||
|
if port > 0 {
|
||||||
|
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
|
||||||
|
opts.listen = Some(librqbit::ListenerOptions {
|
||||||
|
listen_addr: addr,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
crate::logger::log_error("download", &format!("BT 监听端口 {} 无效,使用自动端口", port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 尝试创建会话:先按配置(可能带代理),失败且有代理则降级直连重试
|
||||||
|
let s = match Session::new_with_opts(self.base_dir.clone(), opts).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
if had_proxy {
|
||||||
|
crate::logger::log_error("download", &format!("BT 代理会话初始化失败({}),降级为直连", e));
|
||||||
|
let mut m = lib_session_options();
|
||||||
|
m.trackers = self.session_trackers();
|
||||||
|
if port > 0 {
|
||||||
|
if let Ok(addr) = format!("0.0.0.0:{}", port).parse::<std::net::SocketAddr>() {
|
||||||
|
m.listen = Some(librqbit::ListenerOptions { listen_addr: addr, ..Default::default() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Session::new_with_opts(self.base_dir.clone(), m).await.map_err(|e2| format!("初始化 BitTorrent 会话失败: {}", e2))?
|
||||||
|
} else {
|
||||||
|
return Err(format!("初始化 BitTorrent 会话失败: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*guard = Some(s.clone());
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析磁力链 / 本地 .torrent 文件,返回种子信息(不开始下载)。
|
||||||
|
/// list_only 模式:仅获取元数据,不加入会话,因此不影响后续真正添加。
|
||||||
|
/// 磁力元数据依赖 DHT/tracker 拉取,无超时会永久等待,故加超时。
|
||||||
|
pub async fn inspect(&self, input: &str) -> Result<TorrentInfo, String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
let trackers = self.build_trackers();
|
||||||
|
// 磁力:需从网络拉取元数据,可能较慢(取决于种子热度与网络连通性);
|
||||||
|
// 本地 .torrent / http(s) .torrent URL:元数据在文件内,from_cli_argument 会读取/下载并解析
|
||||||
|
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
|
||||||
|
let add_fut = session.add_torrent(
|
||||||
|
add,
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
list_only: true,
|
||||||
|
trackers: Some(trackers),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let resp = tokio::time::timeout(std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS), add_fut)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
|
||||||
|
.map_err(|e| format!("解析种子失败: {}", e))?;
|
||||||
|
|
||||||
|
let AddTorrentResponse::ListOnly(lo) = resp else {
|
||||||
|
return Err("该链接未返回有效的种子元数据".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = lo.info.info();
|
||||||
|
let name = info
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| String::from_utf8_lossy(b.as_ref()).into_owned())
|
||||||
|
.or_else(|| lo.info.name().map(|c| c.to_string()))
|
||||||
|
.unwrap_or_else(|| "未命名种子".to_string());
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut total_size = 0u64;
|
||||||
|
if let Some(fs) = &info.files {
|
||||||
|
for (i, f) in fs.iter().enumerate() {
|
||||||
|
let path = f
|
||||||
|
.path
|
||||||
|
.iter()
|
||||||
|
.map(|p| String::from_utf8_lossy(p.as_ref()).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
files.push(BtFileInfo {
|
||||||
|
index: i as u32,
|
||||||
|
path,
|
||||||
|
size: f.length,
|
||||||
|
});
|
||||||
|
total_size += f.length;
|
||||||
|
}
|
||||||
|
} else if let Some(len) = info.length {
|
||||||
|
// 单文件种子
|
||||||
|
files.push(BtFileInfo {
|
||||||
|
index: 0,
|
||||||
|
path: name.clone(),
|
||||||
|
size: len,
|
||||||
|
});
|
||||||
|
total_size = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TorrentInfo {
|
||||||
|
name,
|
||||||
|
info_hash: hex_encode(&lo.info_hash.0),
|
||||||
|
total_size,
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异步添加种子:立即返回句柄(不等待元数据),由调用方后台解析。
|
||||||
|
/// 附加公共 tracker 提升磁力元数据解析成功率;上传限速 per-torrent 应用。
|
||||||
|
pub async fn add_async(
|
||||||
|
&self,
|
||||||
|
input: &str,
|
||||||
|
output_dir: &str,
|
||||||
|
) -> Result<(usize, Arc<ManagedTorrent>), String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
std::fs::create_dir_all(output_dir).map_err(|e| format!("创建下载目录失败: {}", e))?;
|
||||||
|
let up = self.upload_limit_bps.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let upload_bps = std::num::NonZeroU32::new(up.min(u32::MAX as u64) as u32);
|
||||||
|
let trackers = self.build_trackers();
|
||||||
|
let opts = AddTorrentOptions {
|
||||||
|
paused: true,
|
||||||
|
output_folder: Some(output_dir.to_string()),
|
||||||
|
overwrite: true,
|
||||||
|
trackers: Some(trackers),
|
||||||
|
ratelimits: librqbit::limits::LimitsConfig {
|
||||||
|
upload_bps,
|
||||||
|
download_bps: None,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let add = AddTorrent::from_cli_argument(input).map_err(|e| format!("无效的种子输入: {}", e))?;
|
||||||
|
let resp = session
|
||||||
|
.add_torrent(add, Some(opts))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("添加种子失败: {}", e))?;
|
||||||
|
match resp {
|
||||||
|
AddTorrentResponse::Added(id, handle) => Ok((id, handle)),
|
||||||
|
AddTorrentResponse::AlreadyManaged(id, handle) => Ok((id, handle)),
|
||||||
|
_ => Err("种子已存在或元数据无效".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等待种子元数据初始化就绪(磁力需要从网络拉取,带超时)
|
||||||
|
pub async fn wait_initialized(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(INSPECT_TIMEOUT_SECS),
|
||||||
|
handle.wait_until_initialized(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "解析磁力元数据超时:未能从 DHT/Tracker 获取种子信息,请确认种子有做种源或网络可直连 BT".to_string())?
|
||||||
|
.map_err(|e| format!("获取种子元数据失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置要下载的种子文件子集(用户文件勾选;传全部索引则全选)
|
||||||
|
pub async fn set_only_files(&self, handle: &Arc<ManagedTorrent>, files: &[u32]) -> Result<(), String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
let set: std::collections::HashSet<usize> = files.iter().map(|&i| i as usize).collect();
|
||||||
|
session
|
||||||
|
.update_only_files(handle, &set)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("设置下载文件失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取句柄当前进度:返回 (已下载字节, 总字节, 每文件已下载字节)
|
||||||
|
/// `file_progress` 与种子文件一一对应,供前端详情页展示单文件进度。
|
||||||
|
pub fn progress_full(handle: &Arc<ManagedTorrent>) -> (u64, u64, Vec<u64>) {
|
||||||
|
let s = handle.stats();
|
||||||
|
(s.progress_bytes, s.total_bytes, s.file_progress.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 暂停种子(下载中的连接会停止,已下载片段保留,可继续)
|
||||||
|
pub async fn pause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
session
|
||||||
|
.pause(handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("暂停种子失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 继续种子(幂等):恢复已暂停的种子。
|
||||||
|
/// 若种子本已处于 Live(例如 select_bt_files 的 set_only_files 内部已恢复过、
|
||||||
|
/// 或暂停期间被其他逻辑恢复),直接视为成功,避免 librqbit 报 "torrent is already live"
|
||||||
|
/// 而导致 pause→resume 链路被误判为失败。
|
||||||
|
pub async fn unpause(&self, handle: &Arc<ManagedTorrent>) -> Result<(), String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
match handle.stats().state {
|
||||||
|
librqbit::TorrentStatsState::Live => Ok(()),
|
||||||
|
librqbit::TorrentStatsState::Error => Err("种子处于错误状态,无法继续".to_string()),
|
||||||
|
librqbit::TorrentStatsState::Paused
|
||||||
|
| librqbit::TorrentStatsState::Initializing { .. } => session
|
||||||
|
.unpause(handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("继续种子失败: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除种子(info_hash 为 hex 字符串;delete_files 是否同时删除已下载文件)
|
||||||
|
pub async fn delete(&self, info_hash: &str, delete_files: bool) -> Result<(), String> {
|
||||||
|
let session = self.get_session().await?;
|
||||||
|
let id = TorrentIdOrHash::parse(info_hash)
|
||||||
|
.map_err(|_| format!("无效的 infohash: {}", info_hash))?;
|
||||||
|
session
|
||||||
|
.delete(id, delete_files)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("删除种子失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取句柄当前下载进度 (已下载字节, 总字节)
|
||||||
|
pub fn progress(handle: &Arc<ManagedTorrent>) -> (u64, u64) {
|
||||||
|
let s = handle.stats();
|
||||||
|
(s.progress_bytes, s.total_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止全局会话(应用退出时调用)
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
let session = {
|
||||||
|
let mut guard = self.session.lock().await;
|
||||||
|
guard.take()
|
||||||
|
};
|
||||||
|
if let Some(s) = session {
|
||||||
|
let _ = s.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字节数组转小写十六进制字符串(librqbit 的 Id 未实现 Display)
|
||||||
|
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for &b in bytes {
|
||||||
|
out.push(HEX[(b >> 4) as usize] as char);
|
||||||
|
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认会话选项(后续如需新增 DHT/缓存等可在此统一配置)
|
||||||
|
fn lib_session_options() -> librqbit::SessionOptions {
|
||||||
|
librqbit::SessionOptions::default()
|
||||||
|
}
|
||||||
+254
-63
@@ -6,43 +6,62 @@ mod download_engine;
|
|||||||
mod logger;
|
mod logger;
|
||||||
mod mihomo_manager;
|
mod mihomo_manager;
|
||||||
mod monitor_kernel;
|
mod monitor_kernel;
|
||||||
|
mod music;
|
||||||
mod network_monitor;
|
mod network_monitor;
|
||||||
mod osd_window;
|
mod osd_window;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
mod quickpanel;
|
mod quickpanel;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
|
mod secrets;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod shortcut;
|
mod shortcut;
|
||||||
mod snap_fix;
|
mod snap_fix;
|
||||||
|
mod translate;
|
||||||
mod tray_menu;
|
mod tray_menu;
|
||||||
|
mod updater;
|
||||||
mod win32_util;
|
mod win32_util;
|
||||||
|
|
||||||
use download_engine::{
|
use download_engine::{
|
||||||
DownloadEngine,
|
DownloadEngine,
|
||||||
downloader_add_task, downloader_check_url, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks,
|
downloader_add_task, downloader_cancel_task, downloader_check_url, downloader_focus_window, downloader_get_extension_info, downloader_get_settings, downloader_get_tasks, downloader_inspect, downloader_select_bt_files,
|
||||||
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_remove_task,
|
downloader_open_dir, downloader_open_url, downloader_pause_task, downloader_redownload, downloader_remove_task,
|
||||||
downloader_resume_task, downloader_save_settings, downloader_status,
|
downloader_resume_task, downloader_save_settings, downloader_status,
|
||||||
};
|
};
|
||||||
use logger::{
|
use logger::{
|
||||||
log_clear, log_info_state, log_list, log_message,
|
log_clear, log_info_state, log_list, log_message,
|
||||||
};
|
};
|
||||||
use mihomo_manager::{
|
use mihomo_manager::{
|
||||||
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection, proxy_delete_profile,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
||||||
proxy_import_profile, proxy_install_kernel, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
|
proxy_import_profile, proxy_kernel_info, proxy_patch_configs, proxy_restart, proxy_save_settings,
|
||||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
|
||||||
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version, MihomoManager,
|
proxy_test_delay, proxy_update_profile, proxy_version, MihomoManager,
|
||||||
};
|
};
|
||||||
use monitor_kernel::{
|
use monitor_kernel::{
|
||||||
monitor_elevate_self, monitor_get_elevate_on_launch, monitor_get_hardware_config,
|
monitor_elevate_self, monitor_get_auto_start, monitor_get_elevate_on_launch,
|
||||||
monitor_get_snapshot, monitor_get_status, monitor_kernel_info, monitor_set_elevate_on_launch,
|
monitor_get_hardware_config, monitor_get_snapshot, monitor_get_status, monitor_kernel_info,
|
||||||
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status,
|
monitor_repair_pawnio, monitor_set_auto_start, monitor_set_elevate_on_launch,
|
||||||
monitor_stop, MonitorKernel,
|
monitor_set_hardware_config, monitor_start, monitor_start_elevated, monitor_status, monitor_stop,
|
||||||
|
MonitorKernel,
|
||||||
|
};
|
||||||
|
use music::{
|
||||||
|
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
|
||||||
|
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
|
||||||
|
feiniu_list_connections, feiniu_list_audio_files, feiniu_list_tracks, feiniu_login,
|
||||||
|
feiniu_logout, feiniu_lyric,
|
||||||
|
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
|
||||||
|
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
|
||||||
|
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
|
||||||
|
music_resolve, music_save_settings, music_search, music_secret_get, music_secret_set,
|
||||||
|
music_stop_bridge, music_update_musicdl, webdav_delete, webdav_get_secret,
|
||||||
|
webdav_save_secret, webdav_test,
|
||||||
|
webdav_upload, MusicManager,
|
||||||
};
|
};
|
||||||
use network_monitor::network_status;
|
use network_monitor::network_status;
|
||||||
use osd_window::{
|
use osd_window::{
|
||||||
osd_apply_overlay_style, osd_begin_drag, osd_set_click_through, osd_set_topmost,
|
osd_apply_overlay_style, osd_begin_drag, osd_set_bounds, osd_set_click_through,
|
||||||
osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch,
|
osd_set_topmost, osd_start_drag_watch, osd_start_game_watch, osd_start_topmost_watch,
|
||||||
|
osd_stop_watch,
|
||||||
};
|
};
|
||||||
use process_manager::{
|
use process_manager::{
|
||||||
process_all_status, process_start, process_status,
|
process_all_status, process_start, process_status,
|
||||||
@@ -50,32 +69,56 @@ use process_manager::{
|
|||||||
};
|
};
|
||||||
use screenshot::commands::{
|
use screenshot::commands::{
|
||||||
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
|
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
|
||||||
screenshot_compose_copy, screenshot_copy_image, screenshot_crop_copy_stored,
|
screenshot_compose_copy, screenshot_compose_png, screenshot_copy_image,
|
||||||
screenshot_crop_stored, screenshot_cursor_pos, screenshot_delete_cache,
|
screenshot_crop_copy_stored, screenshot_crop_stored, screenshot_cursor_pos,
|
||||||
screenshot_disable_transitions, screenshot_enum_windows, screenshot_fullscreen_png,
|
screenshot_delete_cache, screenshot_disable_transitions, screenshot_enum_windows,
|
||||||
screenshot_get_editor_image, screenshot_get_fullscreen_bmp, screenshot_load_cache,
|
screenshot_fullscreen_png, screenshot_get_fullscreen_bmp, screenshot_load_cache,
|
||||||
screenshot_register_shortcut, screenshot_save_cache, screenshot_save_png,
|
screenshot_load_cache_raw, screenshot_pick_list, screenshot_register_pin_shortcut,
|
||||||
screenshot_set_editor_image, screenshot_unregister_shortcut, screenshot_window_from_point,
|
screenshot_register_shortcut,
|
||||||
|
screenshot_save_cache, screenshot_save_png, screenshot_scroll_capture,
|
||||||
|
screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start,
|
||||||
|
screenshot_set_scroll_hole, screenshot_show_overlay, screenshot_take_editor_image_raw,
|
||||||
|
screenshot_unregister_pin_shortcut, screenshot_unregister_shortcut,
|
||||||
};
|
};
|
||||||
use clipboard::{
|
use clipboard::{
|
||||||
ClipboardManager,
|
ClipboardManager,
|
||||||
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
|
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
|
||||||
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
|
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
|
||||||
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
|
clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
|
||||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
clipboard_preview_interacted, clipboard_register_shortcut,
|
||||||
|
clipboard_reveal_preview,
|
||||||
|
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
|
||||||
|
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
|
||||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||||
};
|
};
|
||||||
use quickpanel::{
|
use quickpanel::{
|
||||||
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
quickpanel_apply_rename, quickpanel_batch_extract, quickpanel_build_file_index,
|
||||||
|
quickpanel_clear_app_icon_cache, quickpanel_delete_file, quickpanel_delete_files,
|
||||||
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
||||||
quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index,
|
quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index,
|
||||||
quickpanel_lock_screen, quickpanel_open_file, quickpanel_open_special,
|
quickpanel_list_archives, quickpanel_list_dir, quickpanel_lock_screen, quickpanel_open_file,
|
||||||
quickpanel_register_shortcut, quickpanel_reveal_in_explorer, quickpanel_run_custom_command,
|
quickpanel_open_special, quickpanel_preview_rename, quickpanel_register_shortcut,
|
||||||
quickpanel_run_system_command, quickpanel_save_settings, quickpanel_scan_apps,
|
quickpanel_reveal_in_explorer, quickpanel_run_custom_command, quickpanel_run_system_command,
|
||||||
quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
|
quickpanel_save_settings, quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup,
|
||||||
quickpanel_unregister_shortcut,
|
quickpanel_show_window, quickpanel_unregister_shortcut, quickpanel_focus_main_window,
|
||||||
};
|
};
|
||||||
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
||||||
|
use translate::{
|
||||||
|
translate_abort, translate_apply_shortcuts, translate_copy_text, translate_engine_delete,
|
||||||
|
translate_engine_models, translate_engine_save, translate_engine_test_config,
|
||||||
|
translate_engines_list,
|
||||||
|
translate_get_settings, translate_history_clear, translate_history_delete,
|
||||||
|
translate_history_list, translate_history_set_favorited, translate_ocr_languages,
|
||||||
|
translate_paste_back, translate_popup_edit_mode, translate_popup_hide,
|
||||||
|
translate_popup_prefs_set, translate_popup_ready, translate_popup_resize,
|
||||||
|
translate_popup_set_pinned, translate_preview_popup, translate_run, translate_save_settings,
|
||||||
|
translate_screenshot_region, translate_secret_clear, translate_secret_set,
|
||||||
|
translate_stream_start,
|
||||||
|
};
|
||||||
|
use updater::{
|
||||||
|
app_version, update_check, update_install, update_thinghk_apply, update_thinghk_cancel,
|
||||||
|
update_thinghk_confirm, ThinghkUpdateState,
|
||||||
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn quit_app(app: tauri::AppHandle) {
|
fn quit_app(app: tauri::AppHandle) {
|
||||||
@@ -84,11 +127,16 @@ fn quit_app(app: tauri::AppHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 导出 tauri-specta 生成的 TypeScript 类型与命令绑定(仅 debug 构建,开发时自动刷新)。
|
/// 导出 tauri-specta 生成的 TypeScript 类型与命令绑定(仅 debug 构建,开发时自动刷新)。
|
||||||
/// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot 五个模块;
|
/// 覆盖 proxy / quickpanel / clipboard / download_engine / screenshot / translate 等模块;
|
||||||
/// 豁免清单(返回 serde_json::Value 或 tauri::ipc::Response/Request,specta 无法生成):
|
/// 豁免清单(返回 serde_json::Value 或 tauri::ipc::Response/Request,specta 无法生成):
|
||||||
/// proxy_version / proxy_get_proxies / proxy_get_connections / proxy_patch_configs、
|
/// proxy_version / proxy_get_proxies / proxy_get_connections / proxy_patch_configs、
|
||||||
/// downloader_status / downloader_get_extension_info、
|
/// downloader_status / downloader_get_extension_info、
|
||||||
/// screenshot_get_fullscreen_bmp(返回 ipc::Response)/ screenshot_compose_copy(接收 ipc::Request)。
|
/// screenshot_get_fullscreen_bmp(返回 ipc::Response)/ screenshot_compose_copy(接收 ipc::Request)、
|
||||||
|
/// music_ping / music_get_sources / music_search(返回 serde_json::Value)。
|
||||||
|
///
|
||||||
|
/// 注意:export 结果写入 `../src/lib/bindings.ts`,因此**新增模块的命令后需至少以 debug
|
||||||
|
/// 构建运行一次**,前端才能用 `commands.xxx` 拿到类型。在此之前前端若需调用,
|
||||||
|
/// 只能退回原生 `invoke`(返回类型需自行声明)。
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
fn export_bindings() {
|
fn export_bindings() {
|
||||||
use specta_typescript::Typescript;
|
use specta_typescript::Typescript;
|
||||||
@@ -100,13 +148,16 @@ fn export_bindings() {
|
|||||||
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
// 生成命令失败时直接 throw,与原生 invoke 一致,前端无需解包 helper
|
||||||
.error_handling(ErrorHandlingMode::Throw)
|
.error_handling(ErrorHandlingMode::Throw)
|
||||||
.commands(collect_commands![
|
.commands(collect_commands![
|
||||||
|
// 应用更新(6)
|
||||||
|
app_version, update_check, update_install, update_thinghk_apply,
|
||||||
|
update_thinghk_confirm, update_thinghk_cancel,
|
||||||
// proxy(20)
|
// proxy(20)
|
||||||
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_close_connection, proxy_delete_profile, proxy_get_settings,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_settings,
|
||||||
proxy_get_system_proxy, proxy_import_profile, proxy_install_kernel,
|
proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info,
|
||||||
proxy_kernel_info, proxy_restart, proxy_save_settings,
|
proxy_restart, proxy_save_settings,
|
||||||
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop, proxy_traffic,
|
||||||
proxy_test_delay, proxy_update_kernel, proxy_update_profile,
|
proxy_test_delay, proxy_update_profile,
|
||||||
// quickpanel(22)
|
// quickpanel(22)
|
||||||
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
|
quickpanel_get_settings, quickpanel_save_settings, quickpanel_register_shortcut,
|
||||||
quickpanel_unregister_shortcut, quickpanel_show_popup, quickpanel_hide_popup,
|
quickpanel_unregister_shortcut, quickpanel_show_popup, quickpanel_hide_popup,
|
||||||
@@ -115,26 +166,48 @@ fn export_bindings() {
|
|||||||
quickpanel_scan_apps, quickpanel_get_app_icon, quickpanel_clear_app_icon_cache,
|
quickpanel_scan_apps, quickpanel_get_app_icon, quickpanel_clear_app_icon_cache,
|
||||||
quickpanel_reveal_in_explorer, quickpanel_open_file, quickpanel_get_special_locations,
|
quickpanel_reveal_in_explorer, quickpanel_open_file, quickpanel_get_special_locations,
|
||||||
quickpanel_open_special, quickpanel_delete_file, quickpanel_run_custom_command,
|
quickpanel_open_special, quickpanel_delete_file, quickpanel_run_custom_command,
|
||||||
quickpanel_run_system_command,
|
quickpanel_run_system_command, quickpanel_list_archives, quickpanel_list_dir,
|
||||||
// clipboard(20)
|
quickpanel_batch_extract, quickpanel_preview_rename, quickpanel_apply_rename,
|
||||||
|
quickpanel_delete_files, quickpanel_focus_main_window,
|
||||||
|
// clipboard(23)
|
||||||
clipboard_get_history, clipboard_get_pinned, clipboard_search, clipboard_get_item,
|
clipboard_get_history, clipboard_get_pinned, clipboard_search, clipboard_get_item,
|
||||||
clipboard_set_pinned, clipboard_delete, clipboard_clear, clipboard_copy_back,
|
clipboard_get_thumb, clipboard_set_pinned, clipboard_delete, clipboard_clear,
|
||||||
clipboard_count, clipboard_get_settings, clipboard_save_settings, clipboard_status,
|
clipboard_copy_back, clipboard_count, clipboard_get_settings, clipboard_save_settings,
|
||||||
clipboard_start, clipboard_stop, clipboard_register_shortcut,
|
clipboard_status, clipboard_start, clipboard_stop, clipboard_register_shortcut,
|
||||||
clipboard_unregister_shortcut, clipboard_show_popup, clipboard_hide_popup,
|
clipboard_unregister_shortcut, clipboard_show_popup, clipboard_hide_popup,
|
||||||
clipboard_show_window, clipboard_paste_to_target,
|
clipboard_show_window, clipboard_paste_to_target, clipboard_show_preview,
|
||||||
|
clipboard_hide_preview, clipboard_resize_preview, clipboard_reveal_preview,
|
||||||
|
clipboard_preview_interacted,
|
||||||
// download_engine(10,豁免 2)
|
// download_engine(10,豁免 2)
|
||||||
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
|
downloader_get_tasks, downloader_check_url, downloader_add_task, downloader_pause_task,
|
||||||
downloader_resume_task, downloader_remove_task, downloader_get_settings,
|
downloader_resume_task, downloader_cancel_task, downloader_redownload, downloader_remove_task, downloader_get_settings,
|
||||||
downloader_save_settings, downloader_open_dir, downloader_open_url,
|
downloader_save_settings, downloader_open_dir, downloader_open_url, downloader_focus_window, downloader_inspect, downloader_select_bt_files,
|
||||||
// screenshot(19,豁免 2:get_fullscreen_bmp 返回 ipc::Response、compose_copy 接收 ipc::Request)
|
// music(6,豁免 3:music_ping / music_get_sources / music_search 返回 serde_json::Value)
|
||||||
screenshot_disable_transitions, screenshot_register_shortcut,
|
music_env_status, music_install_runtime, music_cancel_runtime_install,
|
||||||
screenshot_unregister_shortcut, screenshot_capture_fullscreen, screenshot_fullscreen_png,
|
music_stop_bridge, music_get_settings, music_save_settings,
|
||||||
screenshot_clear_fullscreen, screenshot_crop_stored, screenshot_crop_copy_stored,
|
// screenshot(23,豁免 3:get_fullscreen_bmp / take_editor_image_raw 返回 ipc::Response、
|
||||||
screenshot_window_from_point, screenshot_cursor_pos, screenshot_enum_windows,
|
// compose_copy / compose_png 接收 ipc::Request)
|
||||||
screenshot_capture_window, screenshot_set_editor_image, screenshot_get_editor_image,
|
screenshot_disable_transitions, screenshot_show_overlay, screenshot_register_shortcut,
|
||||||
|
screenshot_unregister_shortcut, screenshot_register_pin_shortcut,
|
||||||
|
screenshot_unregister_pin_shortcut, screenshot_capture_fullscreen,
|
||||||
|
screenshot_fullscreen_png, screenshot_clear_fullscreen, screenshot_crop_stored,
|
||||||
|
screenshot_crop_copy_stored, screenshot_pick_list, screenshot_cursor_pos,
|
||||||
|
screenshot_enum_windows, screenshot_capture_window, screenshot_scroll_capture,
|
||||||
|
screenshot_scroll_cancel, screenshot_scroll_finish, screenshot_scroll_start,
|
||||||
|
screenshot_set_scroll_hole,
|
||||||
screenshot_copy_image, screenshot_save_png,
|
screenshot_copy_image, screenshot_save_png,
|
||||||
screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache,
|
screenshot_save_cache, screenshot_load_cache, screenshot_delete_cache,
|
||||||
|
// translate(20)
|
||||||
|
translate_get_settings, translate_save_settings, translate_apply_shortcuts,
|
||||||
|
translate_engines_list, translate_engine_save, translate_engine_delete,
|
||||||
|
translate_secret_set, translate_secret_clear, translate_engine_test_config,
|
||||||
|
translate_engine_models, translate_run, translate_copy_text,
|
||||||
|
translate_preview_popup, translate_popup_ready, translate_popup_hide,
|
||||||
|
translate_popup_edit_mode, translate_popup_set_pinned, translate_popup_prefs_set,
|
||||||
|
translate_popup_resize, translate_screenshot_region, translate_ocr_languages,
|
||||||
|
translate_history_list, translate_history_delete, translate_history_clear,
|
||||||
|
translate_history_set_favorited,
|
||||||
|
translate_stream_start, translate_abort, translate_paste_back,
|
||||||
])
|
])
|
||||||
.export(Typescript::default(), "../src/lib/bindings.ts")
|
.export(Typescript::default(), "../src/lib/bindings.ts")
|
||||||
.expect("failed to export bindings");
|
.expect("failed to export bindings");
|
||||||
@@ -159,8 +232,15 @@ pub fn run() {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.manage(ProcessManager::new())
|
.manage(ProcessManager::new())
|
||||||
|
.manage(ThinghkUpdateState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
quit_app,
|
quit_app,
|
||||||
|
app_version,
|
||||||
|
update_check,
|
||||||
|
update_install,
|
||||||
|
update_thinghk_apply,
|
||||||
|
update_thinghk_confirm,
|
||||||
|
update_thinghk_cancel,
|
||||||
process_start,
|
process_start,
|
||||||
process_stop,
|
process_stop,
|
||||||
process_status,
|
process_status,
|
||||||
@@ -174,11 +254,13 @@ pub fn run() {
|
|||||||
proxy_save_settings,
|
proxy_save_settings,
|
||||||
proxy_kernel_info,
|
proxy_kernel_info,
|
||||||
proxy_check_kernel_update,
|
proxy_check_kernel_update,
|
||||||
proxy_update_kernel,
|
proxy_apply_kernel_update,
|
||||||
proxy_install_kernel,
|
proxy_cancel_kernel_install,
|
||||||
|
proxy_confirm_install,
|
||||||
proxy_status,
|
proxy_status,
|
||||||
proxy_start,
|
proxy_start,
|
||||||
proxy_stop,
|
proxy_stop,
|
||||||
|
proxy_traffic,
|
||||||
proxy_restart,
|
proxy_restart,
|
||||||
proxy_version,
|
proxy_version,
|
||||||
proxy_get_proxies,
|
proxy_get_proxies,
|
||||||
@@ -201,24 +283,70 @@ pub fn run() {
|
|||||||
monitor_elevate_self,
|
monitor_elevate_self,
|
||||||
monitor_stop,
|
monitor_stop,
|
||||||
monitor_get_status,
|
monitor_get_status,
|
||||||
|
monitor_repair_pawnio,
|
||||||
monitor_get_snapshot,
|
monitor_get_snapshot,
|
||||||
monitor_get_elevate_on_launch,
|
monitor_get_elevate_on_launch,
|
||||||
monitor_set_elevate_on_launch,
|
monitor_set_elevate_on_launch,
|
||||||
|
monitor_get_auto_start,
|
||||||
|
monitor_set_auto_start,
|
||||||
monitor_get_hardware_config,
|
monitor_get_hardware_config,
|
||||||
monitor_set_hardware_config,
|
monitor_set_hardware_config,
|
||||||
|
music_env_status,
|
||||||
|
music_install_runtime,
|
||||||
|
music_cancel_runtime_install,
|
||||||
|
music_update_musicdl,
|
||||||
|
music_ping,
|
||||||
|
music_stop_bridge,
|
||||||
|
music_get_sources,
|
||||||
|
music_search,
|
||||||
|
music_parse_playlist,
|
||||||
|
music_resolve,
|
||||||
|
music_get_settings,
|
||||||
|
music_save_settings,
|
||||||
|
music_download,
|
||||||
|
music_download_cancel,
|
||||||
|
feiniu_list_connections,
|
||||||
|
feiniu_save_connection,
|
||||||
|
feiniu_delete_connection,
|
||||||
|
feiniu_activate_connection,
|
||||||
|
feiniu_test_connection,
|
||||||
|
feiniu_login,
|
||||||
|
feiniu_logout,
|
||||||
|
feiniu_get_config,
|
||||||
|
feiniu_list_tracks,
|
||||||
|
feiniu_lyric,
|
||||||
|
feiniu_media_prefix,
|
||||||
|
feiniu_scan_local,
|
||||||
|
feiniu_list_audio_files,
|
||||||
|
feiniu_cache_status,
|
||||||
|
feiniu_cache_clear,
|
||||||
|
feiniu_cache_fetch,
|
||||||
|
feiniu_fnconnect_resolve,
|
||||||
|
webdav_test,
|
||||||
|
webdav_upload,
|
||||||
|
webdav_delete,
|
||||||
|
webdav_get_secret,
|
||||||
|
webdav_save_secret,
|
||||||
|
music_secret_get,
|
||||||
|
music_secret_set,
|
||||||
|
feiniu_delete_local,
|
||||||
network_status,
|
network_status,
|
||||||
osd_apply_overlay_style,
|
osd_apply_overlay_style,
|
||||||
osd_begin_drag,
|
osd_begin_drag,
|
||||||
|
osd_set_bounds,
|
||||||
osd_set_click_through,
|
osd_set_click_through,
|
||||||
osd_set_topmost,
|
osd_set_topmost,
|
||||||
osd_start_drag_watch,
|
osd_start_drag_watch,
|
||||||
osd_start_topmost_watch,
|
osd_start_topmost_watch,
|
||||||
|
osd_start_game_watch,
|
||||||
osd_stop_watch,
|
osd_stop_watch,
|
||||||
downloader_get_tasks,
|
downloader_get_tasks,
|
||||||
downloader_add_task,
|
downloader_add_task,
|
||||||
downloader_check_url,
|
downloader_check_url,
|
||||||
downloader_pause_task,
|
downloader_pause_task,
|
||||||
downloader_resume_task,
|
downloader_resume_task,
|
||||||
|
downloader_cancel_task,
|
||||||
|
downloader_redownload,
|
||||||
downloader_remove_task,
|
downloader_remove_task,
|
||||||
downloader_get_settings,
|
downloader_get_settings,
|
||||||
downloader_save_settings,
|
downloader_save_settings,
|
||||||
@@ -226,10 +354,14 @@ pub fn run() {
|
|||||||
downloader_get_extension_info,
|
downloader_get_extension_info,
|
||||||
downloader_open_dir,
|
downloader_open_dir,
|
||||||
downloader_open_url,
|
downloader_open_url,
|
||||||
|
downloader_focus_window,
|
||||||
|
downloader_inspect,
|
||||||
|
downloader_select_bt_files,
|
||||||
clipboard_get_history,
|
clipboard_get_history,
|
||||||
clipboard_get_pinned,
|
clipboard_get_pinned,
|
||||||
clipboard_search,
|
clipboard_search,
|
||||||
clipboard_get_item,
|
clipboard_get_item,
|
||||||
|
clipboard_get_thumb,
|
||||||
clipboard_set_pinned,
|
clipboard_set_pinned,
|
||||||
clipboard_delete,
|
clipboard_delete,
|
||||||
clipboard_clear,
|
clipboard_clear,
|
||||||
@@ -246,6 +378,11 @@ pub fn run() {
|
|||||||
clipboard_show_window,
|
clipboard_show_window,
|
||||||
clipboard_hide_popup,
|
clipboard_hide_popup,
|
||||||
clipboard_paste_to_target,
|
clipboard_paste_to_target,
|
||||||
|
clipboard_show_preview,
|
||||||
|
clipboard_hide_preview,
|
||||||
|
clipboard_resize_preview,
|
||||||
|
clipboard_reveal_preview,
|
||||||
|
clipboard_preview_interacted,
|
||||||
quickpanel_get_settings,
|
quickpanel_get_settings,
|
||||||
quickpanel_save_settings,
|
quickpanel_save_settings,
|
||||||
quickpanel_register_shortcut,
|
quickpanel_register_shortcut,
|
||||||
@@ -264,10 +401,17 @@ pub fn run() {
|
|||||||
quickpanel_reveal_in_explorer,
|
quickpanel_reveal_in_explorer,
|
||||||
quickpanel_open_file,
|
quickpanel_open_file,
|
||||||
quickpanel_delete_file,
|
quickpanel_delete_file,
|
||||||
|
quickpanel_delete_files,
|
||||||
quickpanel_run_custom_command,
|
quickpanel_run_custom_command,
|
||||||
quickpanel_run_system_command,
|
quickpanel_run_system_command,
|
||||||
quickpanel_get_special_locations,
|
quickpanel_get_special_locations,
|
||||||
quickpanel_open_special,
|
quickpanel_open_special,
|
||||||
|
quickpanel_list_archives,
|
||||||
|
quickpanel_list_dir,
|
||||||
|
quickpanel_batch_extract,
|
||||||
|
quickpanel_preview_rename,
|
||||||
|
quickpanel_apply_rename,
|
||||||
|
quickpanel_focus_main_window,
|
||||||
tray_menu_action,
|
tray_menu_action,
|
||||||
tray_menu_hide,
|
tray_menu_hide,
|
||||||
tray_menu_ready,
|
tray_menu_ready,
|
||||||
@@ -278,27 +422,68 @@ pub fn run() {
|
|||||||
screenshot_clear_fullscreen,
|
screenshot_clear_fullscreen,
|
||||||
screenshot_crop_stored,
|
screenshot_crop_stored,
|
||||||
screenshot_crop_copy_stored,
|
screenshot_crop_copy_stored,
|
||||||
screenshot_window_from_point,
|
screenshot_pick_list,
|
||||||
|
screenshot_show_overlay,
|
||||||
screenshot_cursor_pos,
|
screenshot_cursor_pos,
|
||||||
screenshot_enum_windows,
|
screenshot_enum_windows,
|
||||||
screenshot_capture_window,
|
screenshot_capture_window,
|
||||||
screenshot_set_editor_image,
|
screenshot_scroll_capture,
|
||||||
screenshot_get_editor_image,
|
screenshot_scroll_cancel,
|
||||||
|
screenshot_scroll_finish,
|
||||||
|
screenshot_scroll_start,
|
||||||
|
screenshot_set_scroll_hole,
|
||||||
|
screenshot_take_editor_image_raw,
|
||||||
screenshot_copy_image,
|
screenshot_copy_image,
|
||||||
screenshot_save_png,
|
screenshot_save_png,
|
||||||
screenshot_save_cache,
|
screenshot_save_cache,
|
||||||
screenshot_load_cache,
|
screenshot_load_cache,
|
||||||
|
screenshot_load_cache_raw,
|
||||||
screenshot_delete_cache,
|
screenshot_delete_cache,
|
||||||
screenshot_register_shortcut,
|
screenshot_register_shortcut,
|
||||||
screenshot_unregister_shortcut,
|
screenshot_unregister_shortcut,
|
||||||
|
screenshot_register_pin_shortcut,
|
||||||
|
screenshot_unregister_pin_shortcut,
|
||||||
screenshot_disable_transitions,
|
screenshot_disable_transitions,
|
||||||
screenshot_compose_copy
|
screenshot_compose_copy,
|
||||||
|
screenshot_compose_png,
|
||||||
|
translate_get_settings,
|
||||||
|
translate_save_settings,
|
||||||
|
translate_apply_shortcuts,
|
||||||
|
translate_engines_list,
|
||||||
|
translate_engine_save,
|
||||||
|
translate_engine_delete,
|
||||||
|
translate_secret_set,
|
||||||
|
translate_secret_clear,
|
||||||
|
translate_engine_test_config,
|
||||||
|
translate_engine_models,
|
||||||
|
translate_run,
|
||||||
|
translate_copy_text,
|
||||||
|
translate_preview_popup,
|
||||||
|
translate_popup_ready,
|
||||||
|
translate_popup_hide,
|
||||||
|
translate_popup_edit_mode,
|
||||||
|
translate_popup_set_pinned,
|
||||||
|
translate_popup_prefs_set,
|
||||||
|
translate_popup_resize,
|
||||||
|
translate_screenshot_region,
|
||||||
|
translate_ocr_languages,
|
||||||
|
translate_history_list,
|
||||||
|
translate_history_delete,
|
||||||
|
translate_history_clear,
|
||||||
|
translate_history_set_favorited,
|
||||||
|
translate_stream_start,
|
||||||
|
translate_abort,
|
||||||
|
translate_paste_back
|
||||||
])
|
])
|
||||||
.setup(setup::init)
|
.setup(setup::init)
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
// 仅主窗口拦截关闭(隐藏到托盘)。其他窗口(截图编辑器/OSD 等)
|
||||||
window.hide().ok();
|
// 调用 close() 是真实销毁语义,全局拦截会导致隐藏窗口累积泄漏。
|
||||||
api.prevent_close();
|
if window.label() == constants::windows::MAIN {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
window.hide().ok();
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
@@ -313,16 +498,22 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||||
// cleanup_on_exit 是 async;在事件循环回调中直接 block_on 有 panic 风险且阻塞退出,
|
// cleanup_on_exit 是 async;在事件循环回调中直接 block_on 有 panic 风险且阻塞退出,
|
||||||
// 放到独立 OS 线程执行并限时等待(与托盘旧实现同模式)。
|
// 放到独立 OS 线程执行并通过 channel 限时等待 3s,超时放弃等待直接退出
|
||||||
|
// (进程终止时 OS 回收残留资源),避免清理挂起导致退出卡死。
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let monitor_clone = monitor.inner().clone();
|
let monitor_clone = monitor.inner().clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
monitor_clone.cleanup_on_exit(&app_clone).await;
|
monitor_clone.cleanup_on_exit(&app_clone).await;
|
||||||
});
|
});
|
||||||
})
|
let _ = tx.send(());
|
||||||
.join()
|
});
|
||||||
.ok();
|
let _ = rx.recv_timeout(std::time::Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
if let Some(music) = app.try_state::<MusicManager>() {
|
||||||
|
// 停止音乐桥接进程(kill 快速返回,wait 在后台线程完成)
|
||||||
|
music.cleanup_on_exit();
|
||||||
}
|
}
|
||||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||||
clip.stop();
|
clip.stop();
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! 自动切换节点:后端调度,独立于前端模块是否处于激活状态。
|
||||||
|
//! 应用启动后由 `setup.rs` 启动后台任务,按用户设置的间隔周期性测速并切换到当前最优节点,
|
||||||
|
//! 切换完成后通过 `proxy-auto-switch` 事件通知前端刷新界面。
|
||||||
|
//! 节点候选(目标组 + 地区筛选 + 伪节点过滤)与择优逻辑对前端、后台调度、托盘三处保持一致。
|
||||||
|
|
||||||
|
use futures_util::future::join_all;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
|
use super::{MihomoManager, ProxySettings};
|
||||||
|
use crate::constants::events::PROXY_AUTO_SWITCH;
|
||||||
|
use crate::process_manager::{ProcessManager, ProcessStatus};
|
||||||
|
|
||||||
|
/// 测速目标(同前端/托盘),用于判断节点可用性
|
||||||
|
const TEST_URL: &str = "http://www.gstatic.com/generate_204";
|
||||||
|
/// 单节点测速超时(毫秒)
|
||||||
|
const TEST_TIMEOUT: u32 = 5000;
|
||||||
|
/// 调度轮询粒度(秒)
|
||||||
|
const CHECK_INTERVAL: u64 = 30;
|
||||||
|
|
||||||
|
/// 后端启动自动切换调度任务(幂等,可安全多次调用)。
|
||||||
|
/// 仅在用户开启「自动切换节点」且 mihomo 运行时才会真正执行测速与切换。
|
||||||
|
pub fn start_auto_switch_loop(app: AppHandle) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut last_run: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(CHECK_INTERVAL)).await;
|
||||||
|
|
||||||
|
let enabled = app
|
||||||
|
.try_state::<MihomoManager>()
|
||||||
|
.map(|m: State<'_, MihomoManager>| m.load_settings().auto_switch_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !enabled {
|
||||||
|
last_run = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval_secs = app
|
||||||
|
.try_state::<MihomoManager>()
|
||||||
|
.map(|m: State<'_, MihomoManager>| m.load_settings().auto_switch_interval.max(1) as u64)
|
||||||
|
.unwrap_or(5)
|
||||||
|
* 60;
|
||||||
|
|
||||||
|
let due = match last_run {
|
||||||
|
Some(t) => t.elapsed().as_secs() >= interval_secs,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if !due {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((group, name, delay)) = perform_auto_switch(&app).await {
|
||||||
|
let _ = app.emit(
|
||||||
|
PROXY_AUTO_SWITCH,
|
||||||
|
serde_json::json!({
|
||||||
|
"switched": true,
|
||||||
|
"group": group,
|
||||||
|
"name": name,
|
||||||
|
"delay": delay,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last_run = Some(Instant::now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行一次自动切换:若当前不是最优节点则切换并返回 `(组名, 节点名, 延迟)`,否则返回 `None`。
|
||||||
|
/// 供后台调度任务使用(模块内私有)。
|
||||||
|
async fn perform_auto_switch(app: &AppHandle) -> Option<(String, String, u32)> {
|
||||||
|
let mihomo = app.state::<MihomoManager>();
|
||||||
|
let pm = app.state::<ProcessManager>();
|
||||||
|
|
||||||
|
// 仅在 mihomo 运行时执行
|
||||||
|
let running = matches!(
|
||||||
|
pm.get_status("proxy").map(|p| p.status),
|
||||||
|
Some(ProcessStatus::Running)
|
||||||
|
);
|
||||||
|
if !running {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = mihomo.load_settings();
|
||||||
|
if !settings.auto_switch_enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (group, name, delay, now) = pick_best(&mihomo, &settings).await.ok()??;
|
||||||
|
|
||||||
|
if name != now && mihomo.select_proxy(&group, &name).await.is_ok() {
|
||||||
|
return Some((group, name, delay));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按自动切换设置确定候选组与地区,并发测速并返回当前最优节点(不执行切换)。
|
||||||
|
/// `Some((组名, 最优节点, 延迟, 当前节点))`;无候选或全部超时返回 `Ok(None)`。
|
||||||
|
/// 当自动切换关闭时自动切换目标组为空、地区为空,故退化为「主组 + 全部节点」全量择优,
|
||||||
|
/// 托盘「开启代理」与调度任务复用此函数保证行为一致。
|
||||||
|
pub async fn pick_best(
|
||||||
|
mihomo: &MihomoManager,
|
||||||
|
settings: &ProxySettings,
|
||||||
|
) -> Result<Option<(String, String, u32, String)>, String> {
|
||||||
|
let proxies = mihomo.get_proxies().await?;
|
||||||
|
let map = proxies
|
||||||
|
.get("proxies")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "无法解析代理数据".to_string())?;
|
||||||
|
|
||||||
|
let group_name = if !settings.auto_switch_group.is_empty() {
|
||||||
|
settings.auto_switch_group.clone()
|
||||||
|
} else {
|
||||||
|
match main_selector_group(&map) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let group = match map.get(&group_name) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let now = group
|
||||||
|
.get("now")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut nodes: Vec<String> = group
|
||||||
|
.get("all")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|n| n.as_str().map(|s| s.to_string())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤伪节点(DIRECT、REJECT、流量、套餐等)
|
||||||
|
nodes.retain(|n| !super::is_pseudo_node(n));
|
||||||
|
// 地区筛选
|
||||||
|
if !settings.auto_switch_region.is_empty() {
|
||||||
|
let region = settings.auto_switch_region.clone();
|
||||||
|
nodes.retain(|n| extract_region(n) == region);
|
||||||
|
}
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发测速
|
||||||
|
let futures = nodes.iter().map(|name| async move {
|
||||||
|
let d = mihomo.test_delay(name, TEST_URL, TEST_TIMEOUT).await.ok();
|
||||||
|
(name.clone(), d)
|
||||||
|
}).collect::<Vec<_>>();
|
||||||
|
let results = join_all(futures).await;
|
||||||
|
let best = results
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(n, d)| d.filter(|x| *x > 0).map(|x| (n, x)))
|
||||||
|
.min_by_key(|(_, d)| *d);
|
||||||
|
|
||||||
|
Ok(best.map(|(name, delay)| (group_name, name, delay, now)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主选择分组:选取名为 PROXY/Proxy/节点选择/代理 的 Selector 组;找不到时回退到第一个 Selector 组
|
||||||
|
fn main_selector_group(map: &Map<String, Value>) -> Option<String> {
|
||||||
|
let mut preferred: Option<String> = None;
|
||||||
|
let mut first_selector: Option<String> = None;
|
||||||
|
for (name, val) in map {
|
||||||
|
let kind = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if kind != "Selector" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if first_selector.is_none() {
|
||||||
|
first_selector = Some(name.clone());
|
||||||
|
}
|
||||||
|
if preferred.is_none() && ["PROXY", "Proxy", "节点选择", "代理"].contains(&name.as_str()) {
|
||||||
|
preferred = Some(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferred.or(first_selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提取节点地区(与前端 extractRegion 保持一致):取名称中第一个数字/符号分隔符之前的文本
|
||||||
|
fn extract_region(name: &str) -> String {
|
||||||
|
for (idx, ch) in name.char_indices() {
|
||||||
|
if ch.is_ascii_digit() || matches!(ch, '|' | '-' | '-' | '—' | '(' | '(' | '【') {
|
||||||
|
return name[..idx].trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name.trim().to_string()
|
||||||
|
}
|
||||||
@@ -4,10 +4,18 @@ use tauri::{AppHandle, State};
|
|||||||
|
|
||||||
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
|
use super::system_proxy::{clear_system_proxy_windows, get_system_proxy_windows, set_system_proxy_windows};
|
||||||
use super::{
|
use super::{
|
||||||
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus,
|
KernelInfo, KernelUpdateInfo, MihomoManager, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::process_manager::{ProcessInfo, ProcessManager};
|
use crate::process_manager::{ProcessInfo, ProcessManager, ProcessStatus};
|
||||||
|
|
||||||
|
/// 判断 mihomo 进程是否处于运行状态
|
||||||
|
fn mihomo_running(pm: &ProcessManager) -> bool {
|
||||||
|
matches!(
|
||||||
|
pm.get_status("proxy").map(|p| p.status),
|
||||||
|
Some(ProcessStatus::Running)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -41,25 +49,33 @@ pub async fn proxy_check_kernel_update(
|
|||||||
state.check_kernel_update().await
|
state.check_kernel_update().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取消内核下载/安装(设置取消标志,下载循环轮询后中止)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn proxy_update_kernel(
|
pub fn proxy_cancel_kernel_install(state: State<'_, MihomoManager>) -> Result<(), String> {
|
||||||
state: State<'_, MihomoManager>,
|
state.cancel_kernel_install();
|
||||||
app: AppHandle,
|
Ok(())
|
||||||
mirror_prefix: Option<String>,
|
|
||||||
) -> Result<KernelInfo, String> {
|
|
||||||
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 首次安装内核(与 update_kernel 共用 install_kernel 实现,语义独立便于前端区分场景)
|
/// 前端确认 mihomo 已停止,唤醒等待中的安装流程继续解压替换。
|
||||||
|
/// (下载阶段允许 mihomo 运行以便走系统代理,解压替换前必须停止 mihomo,否则 exe 被占用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn proxy_install_kernel(
|
pub fn proxy_confirm_install(state: State<'_, MihomoManager>) -> Result<(), String> {
|
||||||
|
state.confirm_install();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn proxy_apply_kernel_update(
|
||||||
state: State<'_, MihomoManager>,
|
state: State<'_, MihomoManager>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
mirror_prefix: Option<String>,
|
zip_path: String,
|
||||||
) -> Result<KernelInfo, String> {
|
) -> Result<KernelInfo, String> {
|
||||||
state.install_kernel(&app, mirror_prefix.unwrap_or_default()).await
|
let path = std::path::PathBuf::from(zip_path);
|
||||||
|
state.apply_kernel_update(&app, path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -87,12 +103,19 @@ pub fn proxy_start(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) -> Result<ProcessInfo, String> {
|
) -> Result<ProcessInfo, String> {
|
||||||
let params = state.prepare_for_start(&app)?;
|
let params = state.prepare_for_start(&app)?;
|
||||||
pm.start(params)
|
let info = pm.start(params)?;
|
||||||
|
// 手动启动也遵循「启动时自动开启系统代理」设置
|
||||||
|
state.apply_auto_system_proxy();
|
||||||
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn proxy_stop(pm: State<'_, ProcessManager>) -> Result<(), String> {
|
pub fn proxy_stop(state: State<'_, MihomoManager>, pm: State<'_, ProcessManager>) -> Result<(), String> {
|
||||||
|
// 关闭 mihomo 时同步关闭系统代理(若开启),避免系统代理指向已停止的端口导致断网
|
||||||
|
if get_system_proxy_windows() {
|
||||||
|
let _ = state.disable_system_proxy();
|
||||||
|
}
|
||||||
pm.stop("proxy")
|
pm.stop("proxy")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +133,65 @@ pub async fn proxy_restart(
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("sleep 失败: {}", e))?;
|
.map_err(|e| format!("sleep 失败: {}", e))?;
|
||||||
let params = state.prepare_for_start(&app)?;
|
|
||||||
pm.start(params)
|
// 启动并等待 API 就绪,带有限重试(共 3 次):
|
||||||
|
// - 冷加载大订阅(首次解析 + geo 下载)可能远超过前端 waitForApi 的 10s 预算,
|
||||||
|
// 这里在命令内等满就绪,避免重启成功后仍被前端误判为「重启失败」导致节点不刷新。
|
||||||
|
// - 偶发的端口未及时释放 / 启动瞬间退出,通过重试自愈。
|
||||||
|
let mut last_err = "mihomo 启动失败".to_string();
|
||||||
|
for _ in 0..3 {
|
||||||
|
match start_and_wait(state.inner(), pm.inner(), &app).await {
|
||||||
|
Ok(info) => return Ok(info),
|
||||||
|
Err(e) => {
|
||||||
|
last_err = e;
|
||||||
|
// 上个实例刚退出,多等一会儿释放端口再重试
|
||||||
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|x| format!("sleep 失败: {}", x))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 所有启动尝试均失败:mihomo 已停止,同步关闭系统代理(若开启),避免代理指向已停止端口导致断网
|
||||||
|
if get_system_proxy_windows() {
|
||||||
|
let _ = state.disable_system_proxy();
|
||||||
|
}
|
||||||
|
Err(last_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动 mihomo 并等待其 HTTP API 就绪(最多 20s)。
|
||||||
|
/// - 若启动后进程立即退出,快速返回(不白等满预算),便于外层尽早重试。
|
||||||
|
/// - 若 pm.start 返回「已在运行中」,说明崩溃监控已抢先拉起进程,同样等待其 API 就绪即可。
|
||||||
|
async fn start_and_wait(
|
||||||
|
state: &MihomoManager,
|
||||||
|
pm: &ProcessManager,
|
||||||
|
app: &AppHandle,
|
||||||
|
) -> Result<ProcessInfo, String> {
|
||||||
|
let params = state.prepare_for_start(app)?;
|
||||||
|
if let Err(e) = pm.start(params) {
|
||||||
|
// "已在运行中" = 崩溃监控已拉起,不算失败;其余为上抛
|
||||||
|
if !e.contains("运行中") {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||||
|
loop {
|
||||||
|
// 进程已退出且 API 未就绪 → 启动失败(快速失败,交外层重试)
|
||||||
|
if let Some(st) = pm.get_status("proxy") {
|
||||||
|
if !matches!(st.status, ProcessStatus::Running) {
|
||||||
|
return Err("mihomo 启动后立即退出".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.get_version().await.is_ok() {
|
||||||
|
return pm.get_status("proxy").ok_or_else(|| "mihomo 进程不存在".to_string());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err("mihomo 启动超时,API 无响应".to_string());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -158,6 +238,14 @@ pub async fn proxy_get_connections(
|
|||||||
state.get_connections().await
|
state.get_connections().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn proxy_traffic(
|
||||||
|
state: State<'_, MihomoManager>,
|
||||||
|
) -> Result<TrafficSnapshot, String> {
|
||||||
|
state.traffic_snapshot().await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn proxy_close_connection(
|
pub async fn proxy_close_connection(
|
||||||
@@ -220,7 +308,12 @@ pub fn proxy_activate_profile(
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn proxy_set_system_proxy(
|
pub fn proxy_set_system_proxy(
|
||||||
state: State<'_, MihomoManager>,
|
state: State<'_, MihomoManager>,
|
||||||
|
pm: State<'_, ProcessManager>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// 停机时禁止开启系统代理:否则系统代理指向已停止的端口,会导致所有网络请求失败
|
||||||
|
if !mihomo_running(&pm) {
|
||||||
|
return Err("mihomo 未运行,无法开启系统代理".into());
|
||||||
|
}
|
||||||
let settings = state.load_settings();
|
let settings = state.load_settings();
|
||||||
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
||||||
set_system_proxy_windows(&addr)?;
|
set_system_proxy_windows(&addr)?;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
//! 内核(mihomo.exe)安装 / 更新 / 版本查询。
|
//! 内核(mihomo.exe)安装 / 更新 / 版本查询。
|
||||||
//! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。
|
//! 子模块通过 `impl super::MihomoManager` 为管理器追加方法,可访问父模块私有字段。
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use crate::constants::events::KERNEL_INSTALL_PROGRESS;
|
use crate::constants::events::KERNEL_INSTALL_PROGRESS;
|
||||||
use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager};
|
use super::{InstallProgress, KernelInfo, KernelUpdateInfo, MihomoManager};
|
||||||
|
|
||||||
|
/// 用户主动取消下载的标记错误信息(前端据此静默处理,不弹错误 toast)
|
||||||
|
const KERNEL_CANCELLED: &str = "下载已取消";
|
||||||
|
|
||||||
impl MihomoManager {
|
impl MihomoManager {
|
||||||
// ---------- 内核 ----------
|
// ---------- 内核 ----------
|
||||||
pub fn kernel_info(&self) -> KernelInfo {
|
pub fn kernel_info(&self) -> KernelInfo {
|
||||||
@@ -203,57 +206,15 @@ impl MihomoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载并安装内核(首次安装与更新共用此方法)
|
/// 应用内核更新:下载阶段已由下载模块完成,本方法仅做 need_stop → 解压 → 替换。
|
||||||
/// - mirror_prefix: 用户选择的镜像源前缀(空串=直连 GitHub)
|
/// zip_path: 下载模块下载完成的 zip 文件路径。
|
||||||
/// - 流式下载:实时推送下载进度到前端
|
/// 任何阶段失败都会 emit error 事件,避免前端进度卡住。
|
||||||
/// - zip crate 解压:替代 PowerShell,避免执行策略问题
|
pub async fn apply_kernel_update(&self, app: &AppHandle, zip_path: PathBuf) -> Result<KernelInfo, String> {
|
||||||
/// - 备份旧内核:替换前备份为 .bak
|
self.kernel_cancel.store(false, Ordering::SeqCst);
|
||||||
/// 任何阶段失败都会 emit error 事件,避免前端进度卡在初始状态
|
let _ = self.kernel_cancel_tx.send(false);
|
||||||
pub async fn install_kernel(&self, app: &AppHandle, mirror_prefix: String) -> Result<KernelInfo, String> {
|
let result = self.apply_kernel_inner(app, zip_path).await;
|
||||||
let result = self.install_kernel_inner(app, mirror_prefix).await;
|
|
||||||
if let Err(ref e) = result {
|
if let Err(ref e) = result {
|
||||||
let _ = app.emit(
|
if e != KERNEL_CANCELLED {
|
||||||
KERNEL_INSTALL_PROGRESS,
|
|
||||||
InstallProgress {
|
|
||||||
stage: "error".into(),
|
|
||||||
percent: 0,
|
|
||||||
downloaded_bytes: 0,
|
|
||||||
total_bytes: None,
|
|
||||||
message: e.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn install_kernel_inner(&self, app: &AppHandle, mirror_prefix: String) -> Result<KernelInfo, String> {
|
|
||||||
let info = self.check_kernel_update().await?;
|
|
||||||
let zip_path = self.cores_dir().join("mihomo-update.zip");
|
|
||||||
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
|
|
||||||
|
|
||||||
// 拼接用户选择的镜像源 URL
|
|
||||||
let url = if mirror_prefix.is_empty() {
|
|
||||||
info.download_url.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}{}", mirror_prefix, info.download_url)
|
|
||||||
};
|
|
||||||
let label = if mirror_prefix.is_empty() { "GitHub 直连".to_string() } else { mirror_prefix.clone() };
|
|
||||||
let _ = app.emit(
|
|
||||||
KERNEL_INSTALL_PROGRESS,
|
|
||||||
InstallProgress {
|
|
||||||
stage: "downloading".into(),
|
|
||||||
percent: 0,
|
|
||||||
downloaded_bytes: 0,
|
|
||||||
total_bytes: None,
|
|
||||||
message: format!("正在下载:{}", label),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 单源下载(用户已选择)
|
|
||||||
match self.download_with_progress(app, &url, &zip_path).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("下载失败({}):{}", label, e);
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
KERNEL_INSTALL_PROGRESS,
|
KERNEL_INSTALL_PROGRESS,
|
||||||
InstallProgress {
|
InstallProgress {
|
||||||
@@ -261,11 +222,50 @@ impl MihomoManager {
|
|||||||
percent: 0,
|
percent: 0,
|
||||||
downloaded_bytes: 0,
|
downloaded_bytes: 0,
|
||||||
total_bytes: None,
|
total_bytes: None,
|
||||||
message: msg.clone(),
|
message: e.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = fs::remove_file(&zip_path);
|
}
|
||||||
return Err(msg);
|
}
|
||||||
|
self.kernel_cancel.store(false, Ordering::SeqCst);
|
||||||
|
let _ = self.kernel_cancel_tx.send(false);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_kernel_inner(&self, app: &AppHandle, zip_path: PathBuf) -> Result<KernelInfo, String> {
|
||||||
|
let extract_dir = self.cores_dir().join("mihomo-update-tmp");
|
||||||
|
|
||||||
|
// 检查 zip 文件是否存在
|
||||||
|
if !zip_path.exists() {
|
||||||
|
return Err(format!("下载文件不存在: {}", zip_path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压替换前需要等待前端确认 mihomo 已停止(否则 exe 文件被占用)
|
||||||
|
if self.kernel_cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(KERNEL_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
let _ = app.emit(
|
||||||
|
KERNEL_INSTALL_PROGRESS,
|
||||||
|
InstallProgress {
|
||||||
|
stage: "need_stop".into(),
|
||||||
|
percent: 90,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: "需要停止 mihomo 才能继续安装".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 创建 oneshot 通道等待前端确认
|
||||||
|
let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
*self.install_confirm.lock().unwrap() = Some(tx);
|
||||||
|
let mut cancel_rx = self.kernel_cancel_tx.subscribe();
|
||||||
|
loop {
|
||||||
|
if self.kernel_cancel.load(Ordering::SeqCst) {
|
||||||
|
*self.install_confirm.lock().unwrap() = None;
|
||||||
|
return Err(KERNEL_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut rx => break,
|
||||||
|
_ = cancel_rx.changed() => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,8 +299,6 @@ impl MihomoManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 在解压目录中递归查找 exe 文件
|
// 在解压目录中递归查找 exe 文件
|
||||||
// mihomo zip 内的 exe 名字通常与 zip 同名(如 mihomo-windows-amd64-v3-v1.19.13.exe),
|
|
||||||
// 不是固定的 mihomo.exe,所以查找唯一的 .exe 文件即可
|
|
||||||
let new_exe = self
|
let new_exe = self
|
||||||
.find_exe_in_dir(&extract_dir)
|
.find_exe_in_dir(&extract_dir)
|
||||||
.ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?;
|
.ok_or_else(|| "解压后未找到任何 .exe 文件".to_string())?;
|
||||||
@@ -313,7 +311,7 @@ impl MihomoManager {
|
|||||||
percent: 96,
|
percent: 96,
|
||||||
downloaded_bytes: 0,
|
downloaded_bytes: 0,
|
||||||
total_bytes: None,
|
total_bytes: None,
|
||||||
message: "正在安装...".into(),
|
message: "正在替换内核...".into(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let kernel = self.kernel_path();
|
let kernel = self.kernel_path();
|
||||||
@@ -345,57 +343,6 @@ impl MihomoManager {
|
|||||||
Ok(final_info)
|
Ok(final_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 流式下载并实时推送进度事件
|
|
||||||
async fn download_with_progress(
|
|
||||||
&self,
|
|
||||||
app: &AppHandle,
|
|
||||||
url: &str,
|
|
||||||
dest: &PathBuf,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.get(url)
|
|
||||||
.header("User-Agent", "thing-app")
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("请求失败: {}", e))?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("HTTP {}", resp.status()));
|
|
||||||
}
|
|
||||||
let total = resp.content_length();
|
|
||||||
let mut stream = resp.bytes_stream();
|
|
||||||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
|
||||||
let mut downloaded: u64 = 0;
|
|
||||||
let mut last_percent: u8 = 0;
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
let chunk = chunk.map_err(|e| format!("读取流失败: {}", e))?;
|
|
||||||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
|
||||||
downloaded += chunk.len() as u64;
|
|
||||||
// 下载占总进度的 0-90%
|
|
||||||
let percent = match total {
|
|
||||||
Some(t) if t > 0 => ((downloaded as f64 / t as f64) * 90.0) as u8,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
// 仅在变化超过 1% 时 emit,避免事件轰炸
|
|
||||||
if percent >= last_percent + 1 {
|
|
||||||
last_percent = percent;
|
|
||||||
let _ = app.emit(
|
|
||||||
KERNEL_INSTALL_PROGRESS,
|
|
||||||
InstallProgress {
|
|
||||||
stage: "downloading".into(),
|
|
||||||
percent,
|
|
||||||
downloaded_bytes: downloaded,
|
|
||||||
total_bytes: total,
|
|
||||||
message: format!("已下载 {:.2} MB", downloaded as f64 / 1024.0 / 1024.0),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file.flush().map_err(|e| format!("flush 失败: {}", e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
||||||
fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> {
|
fn extract_zip(&self, zip_path: &PathBuf, dest: &PathBuf) -> Result<(), String> {
|
||||||
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! - [`system_proxy`]:Windows 系统代理开关
|
//! - [`system_proxy`]:Windows 系统代理开关
|
||||||
//! - [`commands`]:Tauri 命令层
|
//! - [`commands`]:Tauri 命令层
|
||||||
|
|
||||||
|
mod autoswitch;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod kernel;
|
mod kernel;
|
||||||
mod profiles;
|
mod profiles;
|
||||||
@@ -13,21 +14,24 @@ mod pseudo;
|
|||||||
mod system_proxy;
|
mod system_proxy;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
|
pub use autoswitch::{pick_best, start_auto_switch_loop};
|
||||||
pub use pseudo::is_pseudo_node;
|
pub use pseudo::is_pseudo_node;
|
||||||
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus};
|
pub use system_proxy::get_system_proxy_windows;
|
||||||
|
pub use types::{InstallProgress, KernelInfo, KernelUpdateInfo, ProfileMeta, ProxySettings, ProxyStatus, TrafficSnapshot};
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
proxy_activate_profile, proxy_check_kernel_update, proxy_clear_system_proxy, proxy_close_connection,
|
proxy_activate_profile, proxy_apply_kernel_update, proxy_cancel_kernel_install, proxy_check_kernel_update, proxy_clear_system_proxy,
|
||||||
proxy_delete_profile, proxy_get_connections, proxy_get_proxies, proxy_get_settings, proxy_get_system_proxy,
|
proxy_close_connection, proxy_confirm_install, proxy_delete_profile, proxy_get_connections, proxy_get_proxies,
|
||||||
proxy_import_profile, proxy_install_kernel, proxy_kernel_info, proxy_patch_configs, proxy_restart,
|
proxy_get_settings, proxy_get_system_proxy, proxy_import_profile, proxy_kernel_info, proxy_traffic,
|
||||||
proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy, proxy_start, proxy_status, proxy_stop,
|
proxy_patch_configs, proxy_restart, proxy_save_settings, proxy_select_proxy, proxy_set_system_proxy,
|
||||||
proxy_test_delay, proxy_update_kernel, proxy_update_profile, proxy_version,
|
proxy_start, proxy_status, proxy_stop, proxy_test_delay, proxy_update_profile, proxy_version,
|
||||||
};
|
};
|
||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_yaml::Value as YamlValue;
|
use serde_yaml::Value as YamlValue;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
@@ -41,10 +45,29 @@ struct SettingsCacheEntry {
|
|||||||
settings: ProxySettings,
|
settings: ProxySettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 流量速率差分基线:记录上次采样的会话总量与时刻,用于计算实时速率
|
||||||
|
struct TrafficBaseline {
|
||||||
|
download_total: u64,
|
||||||
|
upload_total: u64,
|
||||||
|
at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MihomoManager {
|
pub struct MihomoManager {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
client: Client,
|
client: Client,
|
||||||
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
||||||
|
/// 流量速率差分基线:记录上次采样总量与时刻,由两次 /connections 总量差异计算实时速率
|
||||||
|
traffic_baseline: Mutex<Option<TrafficBaseline>>,
|
||||||
|
/// 内核安装/更新的取消标志(前端「停止下载」置位,下载循环轮询后中止)
|
||||||
|
kernel_cancel: Arc<AtomicBool>,
|
||||||
|
/// 取消唤醒通道:让停滞在流式读取(stream.next 最多等 30s)中的下载立即感知取消,
|
||||||
|
/// 否则旧任务会残留最长 30s,期间可能与新任务并发写临时文件/互相重置取消标志
|
||||||
|
kernel_cancel_tx: tokio::sync::watch::Sender<bool>,
|
||||||
|
/// 下载完成后解压替换前的确认通道:mihomo 运行时下载不受影响,但解压替换前
|
||||||
|
/// 必须等前端确认已停止 mihomo(否则 exe 文件被占用)。前端确认后通过
|
||||||
|
/// proxy_confirm_install 命令发送信号唤醒等待。
|
||||||
|
/// 用 std Mutex 而非 tokio Mutex:锁只短暂存取 sender,不跨 await 持有。
|
||||||
|
install_confirm: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MihomoManager {
|
impl MihomoManager {
|
||||||
@@ -61,6 +84,24 @@ impl MihomoManager {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
settings_cache: Mutex::new(None),
|
settings_cache: Mutex::new(None),
|
||||||
|
traffic_baseline: Mutex::new(None),
|
||||||
|
kernel_cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
kernel_cancel_tx: tokio::sync::watch::channel(false).0,
|
||||||
|
install_confirm: std::sync::Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求取消内核安装/更新(由 proxy_cancel_kernel_install 命令调用)
|
||||||
|
pub fn cancel_kernel_install(&self) {
|
||||||
|
self.kernel_cancel.store(true, Ordering::SeqCst);
|
||||||
|
// 唤醒停滞的流式下载循环,使其立即中止而不是等 30s 超时
|
||||||
|
let _ = self.kernel_cancel_tx.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端确认 mihomo 已停止,唤醒等待中的安装流程继续解压替换(由 proxy_confirm_install 命令调用)
|
||||||
|
pub fn confirm_install(&self) {
|
||||||
|
if let Some(tx) = self.install_confirm.lock().unwrap().take() {
|
||||||
|
let _ = tx.send(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +314,29 @@ impl MihomoManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mihomo 启动成功后,若配置了「启动时自动开启系统代理」且当前未开,则开启系统代理。
|
||||||
|
/// 手动启动与 App 自启共用,保证设置语义一致(mihomo 运行期间自动跟随系统代理)。
|
||||||
|
pub fn apply_auto_system_proxy(&self) {
|
||||||
|
let settings = self.load_settings();
|
||||||
|
if !settings.auto_system_proxy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 以注册表实际状态为准判断是否已开启:settings.system_proxy 是会话内标志,
|
||||||
|
// 上次退出 cleanup_on_exit 只清注册表不会回写该标志,重启后会残留 true,
|
||||||
|
// 若用它做守卫会导致「启动时自动开启系统代理」永远被短路而不生效。
|
||||||
|
if system_proxy::get_system_proxy_windows() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
||||||
|
if let Err(e) = system_proxy::set_system_proxy_windows(&addr) {
|
||||||
|
crate::logger::log_warn("mihomo", &format!("自动开启系统代理失败: {}", e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut s = settings;
|
||||||
|
s.system_proxy = true;
|
||||||
|
let _ = self.save_settings(&s);
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
|
/// 应用启动时检查是否需要自动启动 mihomo 和系统代理
|
||||||
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
|
pub fn auto_start_on_launch(&self, app: &AppHandle, pm: &ProcessManager) {
|
||||||
let settings = self.load_settings();
|
let settings = self.load_settings();
|
||||||
@@ -283,13 +347,9 @@ impl MihomoManager {
|
|||||||
Ok(params) => {
|
Ok(params) => {
|
||||||
if let Err(e) = pm.start(params) {
|
if let Err(e) = pm.start(params) {
|
||||||
crate::logger::log_error("mihomo", &format!("自动启动失败: {}", e));
|
crate::logger::log_error("mihomo", &format!("自动启动失败: {}", e));
|
||||||
} else if settings.auto_system_proxy {
|
} else {
|
||||||
// 启动成功后开启系统代理
|
// 启动成功后按「启动时自动开启系统代理」设置决定是否开启系统代理
|
||||||
let addr = format!("127.0.0.1:{}", settings.mixed_port);
|
self.apply_auto_system_proxy();
|
||||||
let _ = system_proxy::set_system_proxy_windows(&addr);
|
|
||||||
let mut s = settings;
|
|
||||||
s.system_proxy = true;
|
|
||||||
let _ = self.save_settings(&s);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -407,6 +467,48 @@ impl MihomoManager {
|
|||||||
self.api_get("/connections").await
|
self.api_get("/connections").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取 /connections 并计算实时流量快照。
|
||||||
|
/// 速率由两次采样的会话总量差分得出;mihomo 重启导致总量回退时自动重置基线。
|
||||||
|
pub async fn traffic_snapshot(&self) -> Result<TrafficSnapshot, String> {
|
||||||
|
let conns = self.get_connections().await?;
|
||||||
|
let upload_total = conns["uploadTotal"].as_u64().unwrap_or(0);
|
||||||
|
let download_total = conns["downloadTotal"].as_u64().unwrap_or(0);
|
||||||
|
let active_connections = conns["connections"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
let (upload_speed, download_speed) = {
|
||||||
|
let base = self.traffic_baseline.lock().unwrap();
|
||||||
|
match base.as_ref() {
|
||||||
|
// 正常差分:总量单调递增才计算速率
|
||||||
|
Some(b) if upload_total >= b.upload_total && download_total >= b.download_total => {
|
||||||
|
let dt = b.at.elapsed().as_secs_f64();
|
||||||
|
if dt > 0.0 {
|
||||||
|
let up = ((upload_total - b.upload_total) as f64 / dt).max(0.0) as u64;
|
||||||
|
let down = ((download_total - b.download_total) as f64 / dt).max(0.0) as u64;
|
||||||
|
(up, down)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无基线或总量回退(mihomo 重启):本帧速率为 0,下方重置基线
|
||||||
|
_ => (0, 0),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
*self.traffic_baseline.lock().unwrap() = Some(TrafficBaseline {
|
||||||
|
download_total,
|
||||||
|
upload_total,
|
||||||
|
at: Instant::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(TrafficSnapshot {
|
||||||
|
download_total,
|
||||||
|
upload_total,
|
||||||
|
download_speed,
|
||||||
|
upload_speed,
|
||||||
|
active_connections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn close_connection(&self, id: &str) -> Result<(), String> {
|
pub async fn close_connection(&self, id: &str) -> Result<(), String> {
|
||||||
self.api_request(
|
self.api_request(
|
||||||
reqwest::Method::DELETE,
|
reqwest::Method::DELETE,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub(crate) fn clear_system_proxy_windows() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) fn get_system_proxy_windows() -> bool {
|
pub fn get_system_proxy_windows() -> bool {
|
||||||
use winreg::enums::*;
|
use winreg::enums::*;
|
||||||
use winreg::RegKey;
|
use winreg::RegKey;
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||||
@@ -67,7 +67,7 @@ pub(crate) fn clear_system_proxy_windows() -> Result<(), String> {
|
|||||||
Err("系统代理仅支持 Windows".into())
|
Err("系统代理仅支持 Windows".into())
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub(crate) fn get_system_proxy_windows() -> bool {
|
pub fn get_system_proxy_windows() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,22 @@ pub struct ProxyStatus {
|
|||||||
pub restart_count: u32,
|
pub restart_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 实时流量快照(由 /connections 的会话总量差分得出实时速率)
|
||||||
|
#[derive(Serialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TrafficSnapshot {
|
||||||
|
/// 本次会话累计下载字节数
|
||||||
|
pub download_total: u64,
|
||||||
|
/// 本次会话累计上传字节数
|
||||||
|
pub upload_total: u64,
|
||||||
|
/// 实时下载速率(字节/秒)
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// 实时上传速率(字节/秒)
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// 当前活跃连接数
|
||||||
|
pub active_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// 内核安装进度事件载荷
|
/// 内核安装进度事件载荷
|
||||||
/// - stage: downloading | extracting | replacing | done | error
|
/// - stage: downloading | extracting | replacing | done | error
|
||||||
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
|
/// - percent: 0-100(无 total_bytes 时为 0,前端按 downloadedBytes 显示)
|
||||||
|
|||||||
@@ -132,6 +132,41 @@ pub fn is_thing_elevated() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== 监控设置持久化 =====================
|
||||||
|
|
||||||
|
/// 监控模块设置(存储于 {app_data_dir}/monitor/settings.json)
|
||||||
|
/// 参照代理模块 ProxySettings 的模式,仅包含需要持久化的运行时开关。
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MonitorSettings {
|
||||||
|
/// 应用启动时自动启动监控内核(默认 false)
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_start: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监控设置文件路径: {app_data_dir}/monitor/settings.json
|
||||||
|
fn settings_path(app_data_dir: &std::path::Path) -> PathBuf {
|
||||||
|
app_data_dir.join("monitor").join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取监控设置(文件不存在或解析失败时返回默认值)
|
||||||
|
pub fn load_monitor_settings(app_data_dir: &std::path::Path) -> MonitorSettings {
|
||||||
|
fs::read_to_string(settings_path(app_data_dir))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<MonitorSettings>(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入监控设置
|
||||||
|
pub fn save_monitor_settings(app_data_dir: &std::path::Path, settings: &MonitorSettings) -> Result<(), String> {
|
||||||
|
let path = settings_path(app_data_dir);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
let content = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
|
||||||
|
fs::write(path, content).map_err(|e| format!("写入设置失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== 永久提权标志持久化 =====================
|
// ===================== 永久提权标志持久化 =====================
|
||||||
|
|
||||||
/// 永久提权标志文件路径: {app_data_dir}/monitor/elevate.json
|
/// 永久提权标志文件路径: {app_data_dir}/monitor/elevate.json
|
||||||
@@ -193,6 +228,8 @@ pub fn check_and_relaunch_if_needed(app_data_dir: &std::path::Path) -> bool {
|
|||||||
pub struct KernelStatus {
|
pub struct KernelStatus {
|
||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
/// PawnIO 驱动是否已安装;旧版内核无此字段,Option 兼容
|
||||||
|
pub pawn_io_installed: Option<bool>,
|
||||||
pub uptime_ms: f64,
|
pub uptime_ms: f64,
|
||||||
pub group_count: u32,
|
pub group_count: u32,
|
||||||
pub sensor_count: u32,
|
pub sensor_count: u32,
|
||||||
@@ -295,6 +332,32 @@ impl MonitorKernel {
|
|||||||
write_elevate_flag(&self.root.join("elevate.json"), enabled)
|
write_elevate_flag(&self.root.join("elevate.json"), enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取监控设置(auto_start 等)
|
||||||
|
pub fn load_settings(&self) -> MonitorSettings {
|
||||||
|
load_monitor_settings(&self.root.parent().unwrap_or(&self.root).to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入监控设置
|
||||||
|
pub fn save_settings(&self, settings: &MonitorSettings) -> Result<(), String> {
|
||||||
|
save_monitor_settings(&self.root.parent().unwrap_or(&self.root).to_path_buf(), settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用启动时检查是否需要自动启动监控内核
|
||||||
|
pub fn auto_start_on_launch(&self, app: &AppHandle) {
|
||||||
|
let settings = self.load_settings();
|
||||||
|
if !settings.auto_start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let monitor = self.clone();
|
||||||
|
let app_handle = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match monitor.start_with_subscription(&app_handle).await {
|
||||||
|
Ok(info) => crate::logger::log_info("monitor", &format!("自动启动成功, pid={:?}", info.pid)),
|
||||||
|
Err(e) => crate::logger::log_warn("monitor", &format!("自动启动跳过: {}", e)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn cores_dir(&self) -> PathBuf {
|
fn cores_dir(&self) -> PathBuf {
|
||||||
self.root.join("cores")
|
self.root.join("cores")
|
||||||
}
|
}
|
||||||
@@ -309,7 +372,9 @@ impl MonitorKernel {
|
|||||||
self.root.join("hardware-config.json")
|
self.root.join("hardware-config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制
|
/// 确保内核就位:若 cores/ 无内核或版本过期(源文件较新),从资源目录复制。
|
||||||
|
/// 同时把 PawnIO_setup.exe(可选资源)复制过去——内核提权启动时会静默安装它,
|
||||||
|
/// 作为 WinRing0 被系统/杀软拦截时读取温度/频率的替代驱动。
|
||||||
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
pub fn prepare_kernel(&self, app: &AppHandle) -> Result<MonitorKernelInfo, String> {
|
||||||
let kernel = self.kernel_path();
|
let kernel = self.kernel_path();
|
||||||
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
if let Ok(src) = app.path().resolve("binaries/ThingHK.exe", BaseDirectory::Resource) {
|
||||||
@@ -324,6 +389,24 @@ impl MonitorKernel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PawnIO 安装器:可选资源,缺失时仅影响自动安装能力(不影响内核运行)
|
||||||
|
if let Ok(setup_src) = app.path().resolve("binaries/PawnIO_setup.exe", BaseDirectory::Resource) {
|
||||||
|
if setup_src.exists() {
|
||||||
|
let setup_dest = self.cores_dir().join("PawnIO_setup.exe");
|
||||||
|
let need_copy = !setup_dest.exists()
|
||||||
|
|| fs::metadata(&setup_src)
|
||||||
|
.and_then(|s| fs::metadata(&setup_dest).map(|d| s.len() != d.len()))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if need_copy {
|
||||||
|
fs::create_dir_all(self.cores_dir()).ok();
|
||||||
|
if let Err(e) = fs::copy(&setup_src, &setup_dest) {
|
||||||
|
crate::logger::log_warn("monitor", &format!("复制 PawnIO_setup.exe 失败: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MonitorKernelInfo {
|
Ok(MonitorKernelInfo {
|
||||||
path: kernel.to_string_lossy().to_string(),
|
path: kernel.to_string_lossy().to_string(),
|
||||||
exists: kernel.exists(),
|
exists: kernel.exists(),
|
||||||
@@ -428,12 +511,20 @@ impl MonitorKernel {
|
|||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
match resp.json::<KernelStatus>().await {
|
match resp.json::<KernelStatus>().await {
|
||||||
Ok(s) if s.ready => {
|
Ok(s) if s.ready => {
|
||||||
|
// PawnIO 诊断:已提权但驱动缺失时,温度/频率等 ring0 传感器大概率无法读取
|
||||||
|
if s.is_admin && s.pawn_io_installed == Some(false) {
|
||||||
|
crate::logger::log_warn(
|
||||||
|
"monitor",
|
||||||
|
"Kernel 已提权但 PawnIO 驱动未安装,CPU 温度/频率可能无法读取(检查 cores/PawnIO_setup.exe 是否随包部署)",
|
||||||
|
);
|
||||||
|
}
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
crate::constants::events::MONITOR_READY,
|
crate::constants::events::MONITOR_READY,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"isAdmin": s.is_admin,
|
"isAdmin": s.is_admin,
|
||||||
"sensorCount": s.sensor_count,
|
"sensorCount": s.sensor_count,
|
||||||
"providers": s.providers,
|
"providers": s.providers,
|
||||||
|
"pawnIoInstalled": s.pawn_io_installed,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -927,6 +1018,52 @@ pub async fn monitor_get_status(state: tauri::State<'_, MonitorKernel>) -> Resul
|
|||||||
state.get_status().await
|
state.get_status().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检测并修复 PawnIO 驱动:确保安装器随内核部署 → 静默安装 → 重启监控内核
|
||||||
|
/// (LHM 打开一次后不会重新发现驱动,装完必须重启才能恢复 CPU 温度/功耗读取)。
|
||||||
|
/// 返回 { installed, needReboot };installed=false 说明未提权或安装失败,交由内核启动自装。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn monitor_repair_pawnio(
|
||||||
|
state: tauri::State<'_, MonitorKernel>,
|
||||||
|
pm: tauri::State<'_, ProcessManager>,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
// 1. 确保 PawnIO_setup.exe 随内核部署
|
||||||
|
state.prepare_kernel(&app)?;
|
||||||
|
let setup = state.cores_dir().join("PawnIO_setup.exe");
|
||||||
|
if !setup.exists() {
|
||||||
|
return Err("未找到 PawnIO_setup.exe 安装器(binaries 资源未随包部署),请重新部署监控内核".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 静默安装驱动(继承当前进程权限;Thing 已提权则直接成功)
|
||||||
|
let mut cmd = std::process::Command::new(&setup);
|
||||||
|
cmd.args(["-install", "-silent"]);
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
let (installed, need_reboot) = match cmd.status() {
|
||||||
|
Ok(status) => {
|
||||||
|
let code = status.code().unwrap_or(-1);
|
||||||
|
match code {
|
||||||
|
3010 => (true, true), // ERROR_SUCCESS_REBOOT_REQUIRED
|
||||||
|
0 => (true, false),
|
||||||
|
_ => (false, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("运行 PawnIO 安装器失败: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 重启监控内核,使 LHM 以 PawnIO 重新打开传感器
|
||||||
|
state.stop_subscription(&app).await;
|
||||||
|
if state.is_elevated() && !is_thing_elevated() {
|
||||||
|
state.shutdown_kernel().await?;
|
||||||
|
state.elevated.store(false, Ordering::SeqCst);
|
||||||
|
state.start_elevated(&app).await?;
|
||||||
|
} else {
|
||||||
|
pm.stop(PROCESS_ID)?;
|
||||||
|
state.start_with_subscription(&app).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "installed": installed, "needReboot": need_reboot }))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
|
pub async fn monitor_get_snapshot(state: tauri::State<'_, MonitorKernel>) -> Result<SensorSnapshot, String> {
|
||||||
state.get_snapshot().await
|
state.get_snapshot().await
|
||||||
@@ -950,6 +1087,25 @@ pub fn monitor_set_elevate_on_launch(
|
|||||||
state.set_elevate_on_launch(enabled)
|
state.set_elevate_on_launch(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查询"应用启动时自动启动监控内核"是否已启用
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn monitor_get_auto_start(
|
||||||
|
state: tauri::State<'_, MonitorKernel>,
|
||||||
|
) -> bool {
|
||||||
|
state.load_settings().auto_start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置/清除"应用启动时自动启动监控内核"开关
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn monitor_set_auto_start(
|
||||||
|
state: tauri::State<'_, MonitorKernel>,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = state.load_settings();
|
||||||
|
settings.auto_start = enabled;
|
||||||
|
state.save_settings(&settings)
|
||||||
|
}
|
||||||
|
|
||||||
/// 查询硬件监控配置(透传 Kernel GET /config/hardware)。
|
/// 查询硬件监控配置(透传 Kernel GET /config/hardware)。
|
||||||
/// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。
|
/// 返回当前配置 + 可用硬件/传感器类型清单,供前端 Dialog 渲染。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
|||||||
|
//! 桥接进程生命周期:spawn(stdio JSON-Lines 协议)→ 请求分发 → 事件转发 → 停止。
|
||||||
|
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||||||
|
//!
|
||||||
|
//! 协议(与 bridge.py 对应):
|
||||||
|
//! 请求 `{"id":1,"method":"ping","params":{}}`
|
||||||
|
//! 响应 `{"id":1,"ok":true,"result":{...}}` 或 `{"id":1,"ok":false,"error":"..."}`
|
||||||
|
//! 无 id 的事件行 `{"event":"download","type":"progress",...}` 由 reader 线程
|
||||||
|
//! 原样转发为 Tauri 事件 `music-download-event`(见 constants::events)。
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
use super::{BridgeEntry, MusicManager, PythonEnv};
|
||||||
|
|
||||||
|
/// 桥接脚本源码(内置,运行时写出到 {root}/bridge.py,避免资源目录配置)
|
||||||
|
const BRIDGE_SCRIPT: &str = include_str!("bridge.py");
|
||||||
|
|
||||||
|
/// 桥接请求错误分类:决定是否允许重启桥接进程重试。
|
||||||
|
/// 应用层错误与超时绝不能触发重启——重启会杀掉正在进行的下载任务。
|
||||||
|
pub(crate) enum BridgeError {
|
||||||
|
/// 传输层错误(进程退出/管道损坏/写入失败/通道关闭)→ 可重启重试
|
||||||
|
Transport(String),
|
||||||
|
/// 应用层错误(桥接正常响应 ok:false)→ 不重启
|
||||||
|
App(String),
|
||||||
|
/// 响应超时 → 不重启(进程可能只是忙,如正在执行长耗时搜索)
|
||||||
|
Timeout(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeError {
|
||||||
|
fn into_message(self) -> String {
|
||||||
|
match self {
|
||||||
|
BridgeError::Transport(m) | BridgeError::App(m) | BridgeError::Timeout(m) => m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicManager {
|
||||||
|
/// 桥接进程是否在运行(存在且未退出)
|
||||||
|
pub fn bridge_running(&self) -> bool {
|
||||||
|
let mut guard = match self.bridge.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
match guard.as_mut() {
|
||||||
|
Some(entry) => entry.child.try_wait().ok().map(|w| w.is_none()).unwrap_or(false),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保桥接进程已启动(已启动则直接返回;并发调用由 start_lock 串行化)
|
||||||
|
pub fn ensure_bridge(&self) -> Result<(), String> {
|
||||||
|
if self.bridge_running() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _guard = self.start_lock().lock().map_err(|e| e.to_string())?;
|
||||||
|
// 二次检查(等待锁期间可能已被其他调用方启动)
|
||||||
|
if self.bridge_running() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// 清理可能残留的旧条目
|
||||||
|
self.bridge.lock().map_err(|e| e.to_string())?.take();
|
||||||
|
let python = self.resolve_python()?;
|
||||||
|
self.spawn_bridge(&python)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动桥接进程:stdin/stdout 管道直连,stderr 写入 runtime/bridge_stderr.log
|
||||||
|
fn spawn_bridge(&self, python: &PythonEnv) -> Result<(), String> {
|
||||||
|
let script = self.bridge_script_path();
|
||||||
|
// 每次启动前重写脚本,保证与当前版本一致(内容固定,成本极低)
|
||||||
|
std::fs::write(&script, BRIDGE_SCRIPT).map_err(|e| format!("写出桥接脚本失败: {}", e))?;
|
||||||
|
|
||||||
|
let stderr_log = self.runtime_dir().join("bridge_stderr.log");
|
||||||
|
let stderr_file = std::fs::File::create(&stderr_log).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&python.exe);
|
||||||
|
cmd.arg(&script);
|
||||||
|
cmd.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::from(stderr_file));
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
// cwd 统一设为模块根目录:musicdl 会在 cwd 落 search_results.pkl 等缓存文件,
|
||||||
|
// 不设置时(系统 Python)会污染应用工作目录(开发期为仓库根目录)
|
||||||
|
if let Some(parent) = script.parent() {
|
||||||
|
cmd.current_dir(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("启动桥接进程失败 (python: {}): {}", python.exe.display(), e))?;
|
||||||
|
crate::process_manager::assign_to_job(&child);
|
||||||
|
|
||||||
|
let stdin = child.stdin.take().ok_or_else(|| "无法获取桥接 stdin".to_string())?;
|
||||||
|
let stdout = child.stdout.take().ok_or_else(|| "无法获取桥接 stdout".to_string())?;
|
||||||
|
|
||||||
|
*self.bridge.lock().map_err(|e| e.to_string())? = Some(BridgeEntry { child, stdin });
|
||||||
|
|
||||||
|
// 启动 stdout reader 线程:按行读取,按 id 分发到 pending;无 id 的事件行转发到前端
|
||||||
|
let pending = self.pending.clone();
|
||||||
|
let app = self.app_handle();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
for line in reader.lines() {
|
||||||
|
let Ok(line) = line else { break };
|
||||||
|
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||||
|
crate::logger::log_warn("music-bridge", &format!("无法解析 stdout: {}", line));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(id) = v.get("id").and_then(|i| i.as_u64()) {
|
||||||
|
if let Some(tx) = pending.lock().ok().and_then(|mut m| m.remove(&id)) {
|
||||||
|
let _ = tx.send(v);
|
||||||
|
}
|
||||||
|
} else if v.get("event").is_some() {
|
||||||
|
// 事件行(下载进度等):原样转发给前端
|
||||||
|
if let Some(app) = app.as_ref() {
|
||||||
|
let _ = app.emit(crate::constants::events::MUSIC_DOWNLOAD_EVENT, &v);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他无 id 行仅记日志
|
||||||
|
crate::logger::log_info("music-bridge", &line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::logger::log_info("music-bridge", "stdout 已关闭,reader 线程退出");
|
||||||
|
});
|
||||||
|
|
||||||
|
crate::logger::log_info(
|
||||||
|
"music-bridge",
|
||||||
|
&format!("桥接进程已启动 (python: {})", python.exe.display()),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止桥接进程:kill 快速返回,wait 移到后台线程;所有 pending 请求置为失败
|
||||||
|
pub fn stop_bridge(&self) {
|
||||||
|
let entry = self.bridge.lock().ok().and_then(|mut b| b.take());
|
||||||
|
if let Some(mut entry) = entry {
|
||||||
|
let _ = entry.child.kill();
|
||||||
|
// drop stdin/stdout 关闭管道端,reader 线程读到 EOF 退出
|
||||||
|
drop(entry.stdin);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = entry.child.wait();
|
||||||
|
});
|
||||||
|
crate::logger::log_info("music-bridge", "桥接进程已停止");
|
||||||
|
}
|
||||||
|
// 通知前端:活动中的下载任务应标记为中断
|
||||||
|
if let Some(app) = self.app_handle() {
|
||||||
|
let _ = app.emit(
|
||||||
|
crate::constants::events::MUSIC_DOWNLOAD_EVENT,
|
||||||
|
serde_json::json!({ "event": "download", "type": "bridge-stopped" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 唤醒所有等待中的请求(以 Null 表示已中止)
|
||||||
|
if let Ok(mut map) = self.pending.lock() {
|
||||||
|
for (_, tx) in map.drain() {
|
||||||
|
let _ = tx.send(serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送一条请求并等待响应(默认 5s 超时)。传输层错误时自动重启重试一次。
|
||||||
|
pub async fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
self.request_with_timeout(method, params, std::time::Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送一条请求并等待响应(自定义超时,供 search 等长耗时操作使用)。
|
||||||
|
/// 仅传输层错误(进程退出/管道损坏)会重启桥接并重试一次;
|
||||||
|
/// 应用层错误(桥接返回 ok:false)与超时不重启——重启会误杀正在下载的任务。
|
||||||
|
pub async fn request_with_timeout(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
self.ensure_bridge()?;
|
||||||
|
match self.request_inner(method, params.clone(), timeout).await {
|
||||||
|
Ok(v) => Ok(v),
|
||||||
|
Err(BridgeError::Transport(_)) => {
|
||||||
|
// 一次重启机会(进程可能已退出/管道损坏)
|
||||||
|
self.stop_bridge();
|
||||||
|
self.ensure_bridge()?;
|
||||||
|
self.request_inner(method, params, timeout)
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::into_message)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into_message()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_inner(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
params: serde_json::Value,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
) -> Result<serde_json::Value, BridgeError> {
|
||||||
|
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel::<serde_json::Value>();
|
||||||
|
self.pending
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| BridgeError::Transport(e.to_string()))?
|
||||||
|
.insert(id, tx);
|
||||||
|
|
||||||
|
// 写入 stdin(同步函数,MutexGuard 在返回时释放,避免跨 await 持有非 Send 值)
|
||||||
|
self.write_request(id, method, ¶ms)
|
||||||
|
.map_err(BridgeError::Transport)?;
|
||||||
|
|
||||||
|
let v = tokio::time::timeout(timeout, rx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| BridgeError::Timeout("桥接响应超时".to_string()))?
|
||||||
|
.map_err(|_| BridgeError::Transport("桥接响应通道已关闭".to_string()))?;
|
||||||
|
|
||||||
|
// 停止桥接时发送 Null 表示中止
|
||||||
|
if v.is_null() {
|
||||||
|
return Err(BridgeError::Transport("桥接进程已停止".into()));
|
||||||
|
}
|
||||||
|
if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||||
|
Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null))
|
||||||
|
} else {
|
||||||
|
Err(BridgeError::App(
|
||||||
|
v.get("error")
|
||||||
|
.and_then(|e| e.as_str())
|
||||||
|
.unwrap_or("桥接返回未知错误")
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入一条请求到桥接 stdin(同步;进程已退出 / 管道损坏时返回错误)
|
||||||
|
fn write_request(&self, id: u64, method: &str, params: &serde_json::Value) -> Result<(), String> {
|
||||||
|
let line = format!(
|
||||||
|
"{{\"id\":{},\"method\":{},\"params\":{}}}\n",
|
||||||
|
id,
|
||||||
|
serde_json::to_string(method).map_err(|e| e.to_string())?,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
let mut guard = self.bridge.lock().map_err(|e| e.to_string())?;
|
||||||
|
let Some(entry) = guard.as_mut() else {
|
||||||
|
return Err("桥接进程未启动".into());
|
||||||
|
};
|
||||||
|
// 进程已退出 → 立即失败,交给外层重启
|
||||||
|
if entry.child.try_wait().map_err(|e| e.to_string())?.is_some() {
|
||||||
|
return Err("桥接进程已退出".into());
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
.stdin
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.map_err(|e| format!("写入桥接 stdin 失败: {}", e))?;
|
||||||
|
entry.stdin.flush().map_err(|e| format!("刷新桥接 stdin 失败: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ping 桥接进程(P0 环境层连通性验证)
|
||||||
|
pub async fn ping(&self) -> Result<serde_json::Value, String> {
|
||||||
|
let v = self.request("ping", serde_json::Value::Null).await?;
|
||||||
|
crate::logger::log_info("music-bridge", &format!("ping 成功: {}", v));
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
//! 音乐模块 Tauri 命令层。
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
normalize_base_url, resolve_base_url, extract_fn_id, FeiniuConnection, MusicEnvStatus,
|
||||||
|
MusicManager, MusicSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 查询环境状态(Python / musicdl / FFmpeg / 桥接进程),设置页「环境检查」面板调用。
|
||||||
|
/// 异步命令:子进程探测在阻塞线程池执行,避免冻结主线程/UI。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn music_env_status(state: State<'_, MusicManager>) -> Result<MusicEnvStatus, String> {
|
||||||
|
state.env_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装便携 Python + musicdl(幂等),全程推送 music-runtime-install-progress 事件
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn music_install_runtime(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<MusicEnvStatus, String> {
|
||||||
|
state.install_runtime(&app).await?;
|
||||||
|
Ok(state.env_status().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消便携运行时安装/下载
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn music_cancel_runtime_install(state: State<'_, MusicManager>) -> Result<(), String> {
|
||||||
|
state.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 musicdl 对齐到本应用锁定的版本(`force = true` 表示强制重装)。
|
||||||
|
///
|
||||||
|
/// 与「安装便携版」的区别:安装的闸门是「能否 import」,所以版本不一致时它什么都不做;
|
||||||
|
/// 更新则是显式对齐到 `MUSICDL_VERSION`。**只升到锁定版本,不升 PyPI 最新版**
|
||||||
|
/// (bridge.py 的 monkey patch 与 musicdl 版本强耦合,见 `update_musicdl` 的说明)。
|
||||||
|
///
|
||||||
|
/// 成功后顺手停掉桥接进程:它可能已经 import 了旧版 musicdl 并缓存了 MusicClient,
|
||||||
|
/// 不重启就会继续用旧代码跑。下一次搜索/下载会自动拉起新进程。
|
||||||
|
///
|
||||||
|
/// 返回更新后的环境状态,前端一次往返即可刷新面板。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_update_musicdl(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
app: AppHandle,
|
||||||
|
force: Option<bool>,
|
||||||
|
) -> Result<MusicEnvStatus, String> {
|
||||||
|
state.update_musicdl(&app, force.unwrap_or(false)).await?;
|
||||||
|
state.stop_bridge();
|
||||||
|
state.env_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ping 桥接进程(未启动则自动拉起),返回 {"version","python"};
|
||||||
|
/// 返回 Value 且未标注 specta:前端直接按 JSON 使用
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_ping(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
state.ping().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止桥接进程
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn music_stop_bridge(state: State<'_, MusicManager>) -> Result<(), String> {
|
||||||
|
state.stop_bridge();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出 musicdl 已注册的全部搜索源(客户端名);返回 Value,未标注 specta
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_get_sources(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
state.request("get_sources", serde_json::Value::Null).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 多源搜索(最长 90s)。sources 为空时桥接使用默认 3 个大陆源;返回 Value,未标注 specta
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_search(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
keyword: String,
|
||||||
|
sources: Option<Vec<String>>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
// proxy_url 为空串表示「明确不走代理」(桥接侧会强制直连、屏蔽系统代理);
|
||||||
|
// qq_cookie 为空表示游客身份(桥接侧不注入 Cookie)
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"keyword": keyword,
|
||||||
|
"sources": sources.unwrap_or_default(),
|
||||||
|
"proxy": proxy_url.unwrap_or_default(),
|
||||||
|
"cookies": qq_cookie.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
state
|
||||||
|
.request_with_timeout("search", params, std::time::Duration::from_secs(90))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析歌单链接(网易云/QQ 等),返回歌曲列表;返回 Value,未标注 specta
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_parse_playlist(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
url: String,
|
||||||
|
sources: Option<Vec<String>>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"url": url,
|
||||||
|
"sources": sources.unwrap_or_default(),
|
||||||
|
"proxy": proxy_url.unwrap_or_default(),
|
||||||
|
"cookies": qq_cookie.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
state
|
||||||
|
.request_with_timeout("parse_playlist", params, std::time::Duration::from_secs(90))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取音乐模块设置
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn music_get_settings(state: State<'_, MusicManager>) -> MusicSettings {
|
||||||
|
state.load_settings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存音乐模块设置(立即生效)
|
||||||
|
///
|
||||||
|
/// 飞牛音乐**连接相关字段一律以磁盘为准**,不接受前端传值:
|
||||||
|
/// 连接列表 / 激活连接 / 旧版单连接字段只由 `feiniu_save_connection`、
|
||||||
|
/// `feiniu_activate_connection`、`feiniu_delete_connection`、`feiniu_login`
|
||||||
|
/// 等专用命令维护。
|
||||||
|
///
|
||||||
|
/// 原因:前端 `musicStore` 只在 init 时读一次整份设置并长期复用快照,
|
||||||
|
/// 若允许它整份回写,删除连接后任意一次设置保存(哪怕是切页触发的)
|
||||||
|
/// 都会把已删除的连接从旧快照里写回来。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn music_save_settings(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
settings: MusicSettings,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let persisted = state.load_settings();
|
||||||
|
let mut next = settings;
|
||||||
|
next.feiniu_connections = persisted.feiniu_connections;
|
||||||
|
next.feiniu_active_id = persisted.feiniu_active_id;
|
||||||
|
next.feiniu_base_url = persisted.feiniu_base_url;
|
||||||
|
next.feiniu_token = persisted.feiniu_token;
|
||||||
|
next.feiniu_username = persisted.feiniu_username;
|
||||||
|
next.feiniu_device_id = persisted.feiniu_device_id;
|
||||||
|
next.feiniu_access_code = persisted.feiniu_access_code;
|
||||||
|
state.save_settings(&next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析歌曲真实下载链接(懒解析:搜索只取元数据,试听/下载前调用)。
|
||||||
|
/// song(单曲)或 songs(批量)二选一;返回 Value,未标注 specta。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_resolve(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
song: Option<serde_json::Value>,
|
||||||
|
songs: Option<Vec<serde_json::Value>>,
|
||||||
|
quality: Option<String>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut list: Vec<serde_json::Value> = songs.unwrap_or_default();
|
||||||
|
if let Some(s) = song {
|
||||||
|
list.insert(0, s);
|
||||||
|
}
|
||||||
|
if list.is_empty() {
|
||||||
|
return Err("未提供歌曲".into());
|
||||||
|
}
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"songs": list,
|
||||||
|
"quality": quality.unwrap_or_default(),
|
||||||
|
"proxy": proxy_url.unwrap_or_default(),
|
||||||
|
"cookies": qq_cookie.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
state
|
||||||
|
.request_with_timeout("resolve", params, std::time::Duration::from_secs(180))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动下载任务(桥接后台工作池执行,立即返回 taskId;进度经 music-download-event 推送)。
|
||||||
|
/// songs 为搜索结果的歌曲 dict(桥接端用 SongInfo.fromdict 重建)。
|
||||||
|
/// 返回 Value,未标注 specta。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_download(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
task_id: String,
|
||||||
|
songs: Vec<serde_json::Value>,
|
||||||
|
savedir: String,
|
||||||
|
lyric: Option<bool>,
|
||||||
|
cover: Option<bool>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
max_concurrent: Option<u32>,
|
||||||
|
quality: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
if songs.is_empty() {
|
||||||
|
return Err("未选择任何歌曲".into());
|
||||||
|
}
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"taskId": task_id,
|
||||||
|
"songs": songs,
|
||||||
|
"savedir": savedir,
|
||||||
|
"lyric": lyric.unwrap_or(true),
|
||||||
|
"cover": cover.unwrap_or(true),
|
||||||
|
"proxy": proxy_url.unwrap_or_default(),
|
||||||
|
"maxConcurrent": max_concurrent.unwrap_or(1).clamp(1, 16),
|
||||||
|
"quality": quality.unwrap_or_default(),
|
||||||
|
"cookies": qq_cookie.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
state
|
||||||
|
.request_with_timeout("download", params, std::time::Duration::from_secs(15))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消下载任务(队列级:正在下载的歌曲会完成,其余标记取消)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn music_download_cancel(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
task_id: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
state
|
||||||
|
.request("cancel", serde_json::json!({ "taskId": task_id }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 飞牛音乐(NAS)客户端(多连接) ============
|
||||||
|
// 全部命令返回 serde_json::Value、不加 specta:前端用裸 invoke,映射在 feiniuStore。
|
||||||
|
|
||||||
|
/// 连接列表 + 激活 id。返回 `{ activeId, list: [{id,name,kind,baseUrl,username,loggedIn,accessCode,insecure}] }`。
|
||||||
|
/// `loggedIn` 的权威是系统凭据管理器里的 token(结构体字段只在凭据库不可用降级时才有值)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
let s = state.load_settings();
|
||||||
|
let list: Vec<_> = s
|
||||||
|
.feiniu_connections
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
json!({
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"kind": c.kind,
|
||||||
|
"baseUrl": c.base_url,
|
||||||
|
"username": c.username,
|
||||||
|
"loggedIn": !c.token.is_empty() || crate::music::secrets::has_feiniu_token(&c.id),
|
||||||
|
"accessCode": c.access_code,
|
||||||
|
"insecure": c.insecure,
|
||||||
|
"fnId": c.fn_id,
|
||||||
|
"relay": c.relay,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(json!({ "activeId": s.feiniu_active_id, "list": list }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增/更新一条连接(不触碰已登录的 token;改地址后需重新登录)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_save_connection(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
connection: FeiniuConnection,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut settings = state.load_settings();
|
||||||
|
let mut conn = connection;
|
||||||
|
conn.base_url = normalize_base_url(&conn.base_url);
|
||||||
|
if conn.id.is_empty() {
|
||||||
|
conn.id = new_conn_id();
|
||||||
|
}
|
||||||
|
let id = conn.id.clone();
|
||||||
|
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == conn.id) {
|
||||||
|
conn.token = existing.token.clone(); // 保留既有 token
|
||||||
|
// relay 是解析结果而非用户输入:同为 fnconnect 时沿用,切换类型则重置
|
||||||
|
conn.relay = conn.kind == "fnconnect" && existing.kind == "fnconnect" && existing.relay;
|
||||||
|
*existing = conn;
|
||||||
|
} else {
|
||||||
|
settings.feiniu_connections.push(conn);
|
||||||
|
}
|
||||||
|
state.save_settings(&settings)?;
|
||||||
|
// 回传 id:新建时前端无需按「地址 + 名称」反查,避免 FnConnect(地址为空)匹配失败
|
||||||
|
Ok(json!({ "ok": true, "id": id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除一条连接;若删的是激活连接,自动切换激活到第一条。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_delete_connection(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut settings = state.load_settings();
|
||||||
|
settings.feiniu_connections.retain(|c| c.id != id);
|
||||||
|
if settings.feiniu_active_id == id {
|
||||||
|
settings.feiniu_active_id = settings
|
||||||
|
.feiniu_connections
|
||||||
|
.first()
|
||||||
|
.map(|c| c.id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
// 旧版单连接字段是迁移逻辑的输入:残留会让「列表为空」再次被迁移出一条连接。
|
||||||
|
// 删除是明确意图,顺手清掉,保证删了就是删了。
|
||||||
|
settings.feiniu_base_url.clear();
|
||||||
|
settings.feiniu_token.clear();
|
||||||
|
settings.feiniu_username.clear();
|
||||||
|
settings.feiniu_device_id.clear();
|
||||||
|
settings.feiniu_access_code.clear();
|
||||||
|
state.save_settings(&settings)?;
|
||||||
|
// 顺带清掉该连接在系统凭据管理器里的 token,避免留下孤儿凭据
|
||||||
|
let _ = crate::music::secrets::secret_delete(&crate::music::secrets::feiniu_token_key(&id));
|
||||||
|
state.feiniu.sync_with_settings(&settings);
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设某连接为激活连接。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_activate_connection(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut settings = state.load_settings();
|
||||||
|
if !settings.feiniu_connections.iter().any(|c| c.id == id) {
|
||||||
|
return Err("连接不存在".into());
|
||||||
|
}
|
||||||
|
settings.feiniu_active_id = id;
|
||||||
|
state.save_settings(&settings)?;
|
||||||
|
state.feiniu.sync_with_settings(&settings);
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录激活/某连接:校验通过后写回该连接的 token/device_id 并启动本地流代理。
|
||||||
|
/// fnconnect 连接会先用 fnId 解析出可达 base_url 再登录。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_login(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
connection_id: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut settings = state.load_settings();
|
||||||
|
let mut conn = settings
|
||||||
|
.feiniu_connections
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == connection_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "连接不存在,请先保存连接".to_string())?;
|
||||||
|
// fnconnect:用 fnId 解析 base_url
|
||||||
|
if conn.kind == "fnconnect" {
|
||||||
|
let fid = extract_fn_id(&conn.fn_id).ok_or_else(|| "FnConnect 连接缺少有效 fnId".to_string())?;
|
||||||
|
let (url, relay) = resolve_base_url(&fid).await?;
|
||||||
|
conn.base_url = url;
|
||||||
|
conn.relay = relay;
|
||||||
|
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||||
|
c.base_url = conn.base_url.clone();
|
||||||
|
c.relay = relay;
|
||||||
|
}
|
||||||
|
state.save_settings(&settings)?;
|
||||||
|
}
|
||||||
|
// 用该连接配置装备运行期
|
||||||
|
let mut tmp = settings.clone();
|
||||||
|
tmp.feiniu_active_id = conn.id.clone();
|
||||||
|
state.feiniu.sync_with_settings(&tmp);
|
||||||
|
|
||||||
|
let (token, device_id) = state.feiniu.login(&conn.base_url, &username, &password).await?;
|
||||||
|
|
||||||
|
// token 存进系统凭据管理器;只有写入失败才降级为明文落 settings.json(并记日志)。
|
||||||
|
// 顺序很重要:先确认凭据库写成功,再决定要不要把明文留在结构体里。
|
||||||
|
let token_key = crate::music::secrets::feiniu_token_key(&connection_id);
|
||||||
|
let token_protected = crate::music::secrets::try_store(&token_key, &token);
|
||||||
|
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||||
|
existing.token = if token_protected { String::new() } else { token.clone() };
|
||||||
|
existing.device_id = device_id;
|
||||||
|
existing.username = username;
|
||||||
|
}
|
||||||
|
settings.feiniu_active_id = connection_id.clone();
|
||||||
|
state.save_settings(&settings)?;
|
||||||
|
state.feiniu.sync_with_settings(&settings);
|
||||||
|
let prefix = state.feiniu.media_prefix().await?;
|
||||||
|
// 不把 token 回传前端:前端除了 mediaPrefix 之外不需要它,少一处明文暴露面
|
||||||
|
Ok(json!({ "ok": true, "mediaPrefix": prefix, "protected": token_protected }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出某连接(清 token,保留地址/账号),代理 Cookie 同步失效。
|
||||||
|
///
|
||||||
|
/// 顺序有讲究:**必须先删凭据库里的 token 再 sync**,
|
||||||
|
/// 否则 `sync_with_settings` 会从凭据库把刚登出的 token 又读回运行期(看起来「登出无效」)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_logout(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut settings = state.load_settings();
|
||||||
|
let _ = crate::music::secrets::secret_delete(&crate::music::secrets::feiniu_token_key(
|
||||||
|
&connection_id,
|
||||||
|
));
|
||||||
|
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||||
|
c.token.clear();
|
||||||
|
}
|
||||||
|
state.save_settings(&settings)?;
|
||||||
|
state.feiniu.sync_with_settings(&settings);
|
||||||
|
state.feiniu.logout();
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试一条连接**草案**能否登录(不持久化任何变更)。
|
||||||
|
///
|
||||||
|
/// 入参是编辑对话框里的完整草案而非连接 id:新建连接在保存前没有 id,
|
||||||
|
/// 若按 id 查库,对话框里的「测试」在保存前必然报 missing required key。
|
||||||
|
/// 探测以「草案作为唯一连接」装备运行期,结束后恢复持久化的激活连接。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_test_connection(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
connection: FeiniuConnection,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let mut conn = connection;
|
||||||
|
conn.base_url = normalize_base_url(&conn.base_url);
|
||||||
|
// fnconnect:先解析 fnId(解析结果只用于本次探测,不回写设置)
|
||||||
|
if conn.kind == "fnconnect" {
|
||||||
|
let fid = extract_fn_id(&conn.fn_id).ok_or_else(|| "FnConnect 连接缺少有效 fnId".to_string())?;
|
||||||
|
let (url, relay) = resolve_base_url(&fid).await?;
|
||||||
|
conn.base_url = url;
|
||||||
|
conn.relay = relay;
|
||||||
|
}
|
||||||
|
if conn.base_url.trim().is_empty() {
|
||||||
|
return Err("请先填写服务器地址或飞牛 ID".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = state.load_settings();
|
||||||
|
let mut tmp = settings.clone();
|
||||||
|
tmp.feiniu_connections = vec![conn.clone()];
|
||||||
|
tmp.feiniu_active_id = conn.id.clone();
|
||||||
|
state.feiniu.sync_with_settings(&tmp);
|
||||||
|
|
||||||
|
let r = state.feiniu.login(&conn.base_url, &username, &password).await;
|
||||||
|
// 探测可能污染运行期:恢复为持久化的激活连接
|
||||||
|
state.feiniu.sync_with_settings(&state.load_settings());
|
||||||
|
match r {
|
||||||
|
Ok(_) => Ok(json!({ "ok": true })),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询当前激活连接配置:{ activeId, baseUrl, username, loggedIn }。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_get_config(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
let settings = state.load_settings();
|
||||||
|
state.feiniu.sync_with_settings(&settings);
|
||||||
|
let active_id = settings.feiniu_active_id;
|
||||||
|
let mut cfg = state.feiniu.config();
|
||||||
|
cfg["activeId"] = json!(active_id);
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页拉取激活连接曲目列表:{ page, size, keyword? } → NAS 原始 data。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_list_tracks(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
page: Option<u32>,
|
||||||
|
size: Option<u32>,
|
||||||
|
keyword: Option<String>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
state.feiniu.sync_with_settings(&state.load_settings());
|
||||||
|
state
|
||||||
|
.feiniu
|
||||||
|
.list_tracks(
|
||||||
|
page.unwrap_or(1).max(1),
|
||||||
|
size.unwrap_or(50).clamp(1, 100),
|
||||||
|
keyword.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取某曲目歌词:{ lyric }(无则空字符串)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_lyric(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
guid: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
state.feiniu.sync_with_settings(&state.load_settings());
|
||||||
|
let text = state.feiniu.lyric(&guid).await?;
|
||||||
|
Ok(json!({ "lyric": text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地媒体地址前缀:{ mediaPrefix }(首次调用惰性启动本地流代理)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_media_prefix(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
state.feiniu.sync_with_settings(&state.load_settings());
|
||||||
|
let prefix = state.feiniu.media_prefix().await?;
|
||||||
|
Ok(json!({ "mediaPrefix": prefix }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 允许访问的本地目录(下载目录 + 自定义曲库目录)。
|
||||||
|
/// 本地文件的**列举与删除**都限定在其中——前端只应能操作曲库范围内的文件。
|
||||||
|
fn allowed_local_roots(state: &MusicManager) -> Vec<String> {
|
||||||
|
let s = state.load_settings();
|
||||||
|
let mut dirs: Vec<String> = vec![s.savedir.clone()];
|
||||||
|
for d in &s.feiniu_local_dirs {
|
||||||
|
if !dirs.iter().any(|x| x == d) {
|
||||||
|
dirs.push(d.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.into_iter().filter(|d| !d.trim().is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 路径是否位于允许的根目录之下。
|
||||||
|
/// 两侧都先 `canonicalize`:`..` 与符号链接因此无法越出根目录。
|
||||||
|
fn is_within_roots(path: &std::path::Path, roots: &[String]) -> bool {
|
||||||
|
let Ok(target) = std::fs::canonicalize(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
roots.iter().any(|r| {
|
||||||
|
std::fs::canonicalize(r)
|
||||||
|
.map(|root| target.starts_with(&root))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出某个下载目录中的音频文件(非递归):`{ items: [{path,name,size,mtim}] }`。
|
||||||
|
///
|
||||||
|
/// 供上传编排在下载目录里定位「刚落盘的文件」——用 `feiniu_scan_local` 会递归遍历
|
||||||
|
/// 整个曲库并解析标签,为一次上传扫全库是纯浪费。不解析标签(匹配只需要文件名与时间)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_list_audio_files(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
dir: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let roots = allowed_local_roots(&state);
|
||||||
|
if roots.is_empty() || !is_within_roots(std::path::Path::new(&dir), &roots) {
|
||||||
|
return Err(format!("拒绝列举曲库目录之外的路径:{dir}"));
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn_blocking(move || crate::music::feiniu::list_audio_files(&dir))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("列举目录失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描本地曲库目录中的音频文件:{ items }(目录 = 下载 savedir + 用户自定义 dirs)。
|
||||||
|
/// 标签解析结果缓存在 CacheManager(size+mtim 未变即复用)。
|
||||||
|
///
|
||||||
|
/// **必须 async + spawn_blocking**:Tauri 中不带 async 的命令在主线程执行,
|
||||||
|
/// 而 walkdir 递归 + lofty 全量标签解析在万级曲库下会阻塞数秒——UI 直接冻住。
|
||||||
|
/// 目录与缓存路径都先取出为自有值,避免把 `State` 借进 'static 的阻塞任务。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
let dirs = allowed_local_roots(&state);
|
||||||
|
let tags_cache = state.feiniu.local_tags_path();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
crate::music::scan_local_dirs(&dirs, Some(tags_cache.as_path()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("本地曲库扫描任务失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放缓存状态:{ count, usedBytes, usedMb }。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_cache_status(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
Ok(state.feiniu.cache_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空播放缓存。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn feiniu_cache_clear(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
state.feiniu.cache_clear();
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缓存一首歌(命中则直接返回):{ path } 或 { cached }。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_cache_fetch(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
guid: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
state.feiniu.sync_with_settings(&state.load_settings());
|
||||||
|
let max_gb = state.load_settings().feiniu_cache_max_gb;
|
||||||
|
let hit = state.feiniu.cache_fetch(&guid, max_gb).await?;
|
||||||
|
Ok(json!({ "path": hit, "cached": hit.is_some() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成一条新连接的 id(时间戳 + 进程号,避免引 rand)。
|
||||||
|
fn new_conn_id() -> String {
|
||||||
|
let n = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("c{n:x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ P6:下载到飞牛 + 曲库增删(WebDAV,无状态) ============
|
||||||
|
|
||||||
|
/// 测试 WebDAV 连接与目标目录是否可达。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn webdav_test(
|
||||||
|
config: crate::music::feiniu::webdav::WebDavConfig,
|
||||||
|
dir: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let msg = crate::music::feiniu::webdav::test(&config, &dir).await?;
|
||||||
|
Ok(json!({ "ok": true, "message": msg }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传本地文件到 WebDAV 目标目录(流式,不整载内存)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn webdav_upload(
|
||||||
|
config: crate::music::feiniu::webdav::WebDavConfig,
|
||||||
|
local_path: String,
|
||||||
|
remote_path: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let name = crate::music::feiniu::webdav::upload_file(
|
||||||
|
&config,
|
||||||
|
std::path::Path::new(&local_path),
|
||||||
|
&remote_path,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(json!({ "ok": true, "name": name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 WebDAV 远程文件(404 视为已删除)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn webdav_delete(
|
||||||
|
config: crate::music::feiniu::webdav::WebDavConfig,
|
||||||
|
remote_path: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
crate::music::feiniu::webdav::delete(&config, &remote_path).await?;
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 敏感串(系统凭据管理器,DPAPI 保护) ============
|
||||||
|
// 统一实现在 crate::music::secrets:WebDAV 账号密码、QQ 音乐 Cookie、飞牛登录 token
|
||||||
|
// 三类凭据同构存放,不再出现「一类进凭据库、另一类明文落盘」的双标。
|
||||||
|
|
||||||
|
/// 读取 WebDAV 凭据。未配置时 username/password 为 null。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn webdav_get_secret() -> Result<serde_json::Value, String> {
|
||||||
|
let parsed = crate::music::secrets::secret_read(crate::music::secrets::KEY_WEBDAV)
|
||||||
|
.unwrap_or(None)
|
||||||
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok());
|
||||||
|
Ok(parsed.unwrap_or_else(|| json!({ "username": null, "password": null })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn webdav_save_secret(username: String, password: String) -> Result<serde_json::Value, String> {
|
||||||
|
let blob = json!({ "username": username, "password": password }).to_string();
|
||||||
|
crate::music::secrets::secret_write(crate::music::secrets::KEY_WEBDAV, &blob)?;
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验前端可访问的凭据键:只允许白名单内的键(见 `secrets::FRONTEND_KEYS`)。
|
||||||
|
///
|
||||||
|
/// 白名单刻意不含 `webdav-credentials` 与飞牛 token——
|
||||||
|
/// 前端因此无法通过这两个通用命令去读写它们,只能碰自己的 QQ 音乐 Cookie。
|
||||||
|
fn assert_frontend_secret_key(key: &str) -> Result<(), String> {
|
||||||
|
if crate::music::secrets::frontend_key_allowed(key) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("不允许访问的凭据键:{key}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取一个音乐模块的敏感串(如 QQ 音乐 Cookie):{ value }(未设置 → null)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn music_secret_get(key: String) -> Result<serde_json::Value, String> {
|
||||||
|
assert_frontend_secret_key(&key)?;
|
||||||
|
Ok(json!({ "value": crate::music::secrets::secret_read(&key)? }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 / 清除一个音乐模块的敏感串。`value` 为空表示删除该凭据。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn music_secret_set(key: String, value: String) -> Result<serde_json::Value, String> {
|
||||||
|
assert_frontend_secret_key(&key)?;
|
||||||
|
if value.is_empty() {
|
||||||
|
crate::music::secrets::secret_delete(&key)?;
|
||||||
|
} else {
|
||||||
|
crate::music::secrets::secret_write(&key, &value)?;
|
||||||
|
}
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。
|
||||||
|
/// 仅允许音频 / 歌词 / 封面扩展名,拒绝目录,且**必须落在已配置的下载/曲库目录内**——
|
||||||
|
/// 只校验扩展名的话,前端一旦传错路径就能删掉用户的任意音乐文件。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn feiniu_delete_local(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
const ALLOWED: [&str; 13] = [
|
||||||
|
"mp3", "flac", "wav", "m4a", "aac", "ogg", "ape", "wma", "lrc", "jpg", "jpeg", "png", "webp",
|
||||||
|
];
|
||||||
|
let p = std::path::Path::new(&path);
|
||||||
|
let ext_ok = p
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| ALLOWED.contains(&e.to_lowercase().as_str()))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ext_ok {
|
||||||
|
return Err(format!("拒绝删除非媒体文件:{path}"));
|
||||||
|
}
|
||||||
|
let meta = std::fs::metadata(p).map_err(|e| format!("文件不存在: {e}"))?;
|
||||||
|
if meta.is_dir() {
|
||||||
|
return Err("拒绝删除目录".to_string());
|
||||||
|
}
|
||||||
|
if !is_within_roots(p, &allowed_local_roots(&state)) {
|
||||||
|
return Err(format!("拒绝删除曲库目录之外的文件:{path}"));
|
||||||
|
}
|
||||||
|
std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))?;
|
||||||
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ P7:FnConnect 远程连接解析 ============
|
||||||
|
|
||||||
|
/// 解析 fnId → 可达 base_url(探测后返回)。命令层在 fnconnect 连接登录前调用。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_fnconnect_resolve(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
fn_id: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let _ = &state;
|
||||||
|
let (url, relay) = resolve_base_url(&fn_id).await?;
|
||||||
|
Ok(json!({ "baseUrl": url, "relay": relay }))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! 飞牛音乐播放缓存(容量限制 + LRU 逐出)。
|
||||||
|
//!
|
||||||
|
//! 缓存目录:`{app_data}/music/cache`,文件 `guid.<ext>`(ext 缺省记 `bin`)。
|
||||||
|
//! 元数据:`index.json` → `{ "guid": { "size", "lastUsed", "file" } }`。
|
||||||
|
//! 超上限按 lastUsed 升序逐出,直到总占用低于上限。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// 单条缓存元数据
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct CacheEntry {
|
||||||
|
pub size: u64,
|
||||||
|
pub last_used: u64,
|
||||||
|
pub file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CacheManager {
|
||||||
|
root: PathBuf,
|
||||||
|
index_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheManager {
|
||||||
|
pub fn new(app_data_dir: &Path) -> Self {
|
||||||
|
let root = app_data_dir.join("music").join("cache");
|
||||||
|
fs::create_dir_all(&root).ok();
|
||||||
|
let index_path = root.join("index.json");
|
||||||
|
Self { root, index_path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地曲库标签缓存文件:{cache_root}/local-tags.json。
|
||||||
|
/// 记录 path -> {size, mtim, 标签},扫描时未变化的文件直接复用,
|
||||||
|
/// 避免万级曲库每次都重新解析音频标签。
|
||||||
|
pub fn local_tags_path(&self) -> PathBuf {
|
||||||
|
self.root.join("local-tags.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_index(&self) -> HashMap<String, CacheEntry> {
|
||||||
|
fs::read_to_string(&self.index_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_index(&self, idx: &HashMap<String, CacheEntry>) {
|
||||||
|
if let Ok(s) = serde_json::to_string(idx) {
|
||||||
|
fs::write(&self.index_path, s).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms(&self) -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 命中缓存:更新 lastUsed 并返回文件路径。
|
||||||
|
pub fn hit(&self, guid: &str) -> Option<String> {
|
||||||
|
let mut idx = self.load_index();
|
||||||
|
if let Some(e) = idx.get_mut(guid) {
|
||||||
|
let path = self.root.join(&e.file);
|
||||||
|
if path.exists() {
|
||||||
|
e.last_used = self.now_ms();
|
||||||
|
self.save_index(&idx);
|
||||||
|
return Some(path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
idx.remove(guid);
|
||||||
|
self.save_index(&idx);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入缓存(流式 chunk);按上限(GB)逐出。返回写入总字节数。
|
||||||
|
pub async fn put<E: std::fmt::Display>(
|
||||||
|
&self,
|
||||||
|
guid: &str,
|
||||||
|
ext: &str,
|
||||||
|
max_gb: u32,
|
||||||
|
mut stream: impl futures_util::Stream<Item = Result<bytes::Bytes, E>> + Unpin,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
let safe_guid = sanitize(guid);
|
||||||
|
let ext = if ext.is_empty() { "bin" } else { ext };
|
||||||
|
let file = format!("{safe_guid}.{ext}");
|
||||||
|
let path = self.root.join(&file);
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
let mut f = fs::File::create(&path).map_err(|e| format!("创建缓存文件失败: {e}"))?;
|
||||||
|
while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await {
|
||||||
|
let chunk = chunk.map_err(|e| format!("读取流失败: {e}"))?;
|
||||||
|
total += chunk.len() as u64;
|
||||||
|
f.write_all(chunk.as_ref()).map_err(|e| format!("写入缓存失败: {e}"))?;
|
||||||
|
}
|
||||||
|
f.flush().ok();
|
||||||
|
|
||||||
|
let mut idx = self.load_index();
|
||||||
|
idx.insert(
|
||||||
|
guid.to_string(),
|
||||||
|
CacheEntry {
|
||||||
|
size: total,
|
||||||
|
last_used: self.now_ms(),
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.save_index(&idx);
|
||||||
|
self.evict(max_gb);
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按上限(GB)逐出。max_gb==0 视为全部清空。
|
||||||
|
fn evict(&self, max_gb: u32) {
|
||||||
|
if max_gb == 0 {
|
||||||
|
self.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_bytes = max_gb as u64 * 1024 * 1024 * 1024;
|
||||||
|
let mut idx = self.load_index();
|
||||||
|
let mut total: u64 = idx.values().map(|e| e.size).sum();
|
||||||
|
let mut order: Vec<(String, u64)> = idx.iter().map(|(g, e)| (g.clone(), e.last_used)).collect();
|
||||||
|
order.sort_by_key(|(_, t)| *t);
|
||||||
|
for (guid, _) in order {
|
||||||
|
if total <= max_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(e) = idx.remove(&guid) {
|
||||||
|
let _ = fs::remove_file(self.root.join(&e.file));
|
||||||
|
total = total.saturating_sub(e.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.save_index(&idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前占用与条目数。
|
||||||
|
pub fn status(&self) -> serde_json::Value {
|
||||||
|
let idx = self.load_index();
|
||||||
|
let total: u64 = idx.values().map(|e| e.size).sum();
|
||||||
|
json!({
|
||||||
|
"count": idx.len(),
|
||||||
|
"usedBytes": total,
|
||||||
|
"usedMb": (total as f64 / 1024.0 / 1024.0 * 10.0).round() / 10.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) {
|
||||||
|
let idx = self.load_index();
|
||||||
|
for e in idx.values() {
|
||||||
|
let _ = fs::remove_file(self.root.join(&e.file));
|
||||||
|
}
|
||||||
|
fs::remove_file(&self.index_path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! 飞牛音乐原生接口纯函数工具(登录签名、地址规范化、设备 ID)。
|
||||||
|
//! 接口路径/认证方式对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方实现。
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// 规范化服务器地址:去首尾空白、去尾部各层斜杠、去误粘贴的 `/music/api/v1` 后缀。
|
||||||
|
pub fn normalize_base_url(input: &str) -> String {
|
||||||
|
let mut u = input.trim().trim_end_matches('/').to_string();
|
||||||
|
let lower = u.to_lowercase();
|
||||||
|
if lower.ends_with("/music/api/v1") {
|
||||||
|
u = u[..u.len() - "/music/api/v1".len()].to_string();
|
||||||
|
}
|
||||||
|
u.trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 十六进制(登录时密码签名,对齐原生客户端 `sha256Hex(password)`)。
|
||||||
|
pub fn sha256_hex(input: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32 位 hex 设备 ID(首次生成后落 settings 复用;不依赖 rand,用时间戳+进程号哈希)。
|
||||||
|
pub fn generate_device_id() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
sha256_hex(&format!("{}-{}", nanos, std::process::id()))[..32].to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! FnConnect 远程连接解析。
|
||||||
|
//!
|
||||||
|
//! 链路参考第三方客户端 FnMusic 的 `fn_connection_probe_service.dart`:
|
||||||
|
//! fnId → 网关 `<网关>/api/v1/fn/con`(authx md5 签名)→ 内网 / 公网 IPv6 / 公网 IPv4 / 中继
|
||||||
|
//! 候选 → 并发探测(按优先级早停)→ 首个可达的 base_url。
|
||||||
|
//!
|
||||||
|
//! 关键约束(实测确认):
|
||||||
|
//! 1. authx 原文为 `PREFIX_url_nonce_timestamp_md5(body)_API_KEY`,
|
||||||
|
//! md5 与 API_KEY 之间是**单个**下划线;多一个下划线网关即返回 `invalid sign`。
|
||||||
|
//! 2. 中继地址(`<fnId>.fnos.net`)必须携带 `Cookie: mode=relay` 才会被网关转发到
|
||||||
|
//! NAS 音乐后端,否则网关直接 302 回登录页。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::stream::{FuturesUnordered, StreamExt};
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// 网关主机(按序回退;5ddd.com 与 fnos.net 为同一服务的不同集群入口)。
|
||||||
|
const FN_CONNECT_HOSTS: [&str; 2] = ["https://5ddd.com", "https://fnos.net"];
|
||||||
|
/// 连接参数接口路径(签名原文中的 url 部分)。
|
||||||
|
const FN_CON_PATH: &str = "/api/v1/fn/con";
|
||||||
|
/// 签名常量(对齐第三方实现)。
|
||||||
|
const FN_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh";
|
||||||
|
const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c";
|
||||||
|
|
||||||
|
/// 网关查询超时。
|
||||||
|
const FN_QUERY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// 直连候选(内网/公网 IP)探测超时。
|
||||||
|
const FN_PROBE_TIMEOUT_DIRECT: Duration = Duration::from_secs(3);
|
||||||
|
/// 中继候选探测超时(走公网网关,放宽)。
|
||||||
|
const FN_PROBE_TIMEOUT_RELAY: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
fn md5_hex(input: &str) -> String {
|
||||||
|
let mut h = Md5::new();
|
||||||
|
h.update(input.as_bytes());
|
||||||
|
format!("{:x}", h.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从输入识别 fnId:`fnos.net/<id>`、`5ddd.com/<id>`、`<id>.fnos.net`、`<id>.5ddd.com`、或裸 fnId。
|
||||||
|
pub fn extract_fn_id(input: &str) -> Option<String> {
|
||||||
|
let s = input.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// 去协议后统一按「可能带路径的 host」处理
|
||||||
|
let mut t = s;
|
||||||
|
for p in ["https://", "http://"] {
|
||||||
|
if let Some(r) = t.strip_prefix(p) {
|
||||||
|
t = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let t = t.trim_end_matches('/');
|
||||||
|
// 形如 <网关>/<id>
|
||||||
|
for gw in ["fnos.net/", "5ddd.com/"] {
|
||||||
|
if let Some((_, rest)) = t.split_once(gw) {
|
||||||
|
let id = rest.split('/').next().unwrap_or("").trim();
|
||||||
|
if !id.is_empty() {
|
||||||
|
return Some(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 形如 <id>.<网关>
|
||||||
|
for suffix in [".fnos.net", ".5ddd.com"] {
|
||||||
|
if let Some(id) = t.strip_suffix(suffix) {
|
||||||
|
if !id.is_empty() {
|
||||||
|
return Some(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 裸 fnId
|
||||||
|
if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||||
|
return Some(s.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算网关 authx 签名。
|
||||||
|
///
|
||||||
|
/// 原文(下划线连接,**注意 md5 与 API_KEY 之间只有一个下划线**):
|
||||||
|
/// `PREFIX_url_nonce_timestamp_md5(body)_API_KEY` → 取 md5 作为 sign。
|
||||||
|
fn fn_authx(method: &str, url: &str, data: &Value) -> String {
|
||||||
|
let body = if method.eq_ignore_ascii_case("get") {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(data).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut nonce = String::new();
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
for _ in 0..6 {
|
||||||
|
nonce.push(char::from(b'0' + (rng.next_u32() % 10) as u8));
|
||||||
|
}
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let raw = format!(
|
||||||
|
"{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}_{FN_API_KEY}",
|
||||||
|
md5_hex(&body)
|
||||||
|
);
|
||||||
|
format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从网关查询 fnId 的连接参数(多网关按序回退)。
|
||||||
|
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(FN_QUERY_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let body = json!({ "fnId": fn_id });
|
||||||
|
let authx = fn_authx("post", FN_CON_PATH, &body);
|
||||||
|
|
||||||
|
let mut last_err = String::from("FnConnect 网关不可达");
|
||||||
|
for host in FN_CONNECT_HOSTS {
|
||||||
|
let resp = match client
|
||||||
|
.post(format!("{host}{FN_CON_PATH}"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("authx", authx.clone())
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
last_err = format!("FnConnect 网关不可达: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let b: Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
last_err = format!("FnConnect 网关响应异常: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if b["code"].as_i64().unwrap_or(-1) != 0 {
|
||||||
|
last_err = b["msg"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "FnConnect 网关返回错误".to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(b["data"].clone());
|
||||||
|
}
|
||||||
|
Err(last_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去掉 `host:port` 形式的端口(仅用于中继域名,中继恒走 443)。
|
||||||
|
fn strip_port(addr: &str) -> &str {
|
||||||
|
match addr.rsplit_once(':') {
|
||||||
|
Some((host, port)) if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => host,
|
||||||
|
_ => addr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建候选 base_url 列表。返回 `(url, is_relay)`,顺序即优先级:
|
||||||
|
/// 内网 IPv4 → 公网 IPv6 → 公网 IPv4 → 中继。
|
||||||
|
///
|
||||||
|
/// IP 直连地址 HTTP 优先、HTTPS 兜底(自签证书场景 HTTP 更易通);
|
||||||
|
/// `forbbidPublicIpv6` 为真时跳过公网 IPv6。
|
||||||
|
pub fn build_candidates(data: &Value, fn_id: &str) -> Vec<(String, bool)> {
|
||||||
|
let mut out: Vec<(String, bool)> = Vec::new();
|
||||||
|
let port = &data["port"];
|
||||||
|
let http = port["httpPort"].as_u64().unwrap_or(5666);
|
||||||
|
let https = port["httpsPort"].as_u64().unwrap_or(5667);
|
||||||
|
let empty: Vec<Value> = Vec::new();
|
||||||
|
|
||||||
|
let strings = |key: &str| -> Vec<String> {
|
||||||
|
data[key]
|
||||||
|
.as_array()
|
||||||
|
.unwrap_or(&empty)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) 内网 IPv4
|
||||||
|
for ip in strings("ipv4") {
|
||||||
|
out.push((format!("http://{ip}:{http}"), false));
|
||||||
|
out.push((format!("https://{ip}:{https}"), false));
|
||||||
|
}
|
||||||
|
// 2) 公网 IPv6(NAS 侧可禁用)
|
||||||
|
if !data["forbbidPublicIpv6"].as_bool().unwrap_or(false) {
|
||||||
|
for ip in strings("publicIpv6") {
|
||||||
|
out.push((format!("http://[{ip}]:{http}"), false));
|
||||||
|
out.push((format!("https://[{ip}]:{https}"), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) 公网 IPv4
|
||||||
|
for ip in strings("publicIpv4") {
|
||||||
|
out.push((format!("http://{ip}:{http}"), false));
|
||||||
|
out.push((format!("https://{ip}:{https}"), false));
|
||||||
|
}
|
||||||
|
// 4) 中继:仅 HTTPS;网关未返回时按两种集群域名兜底
|
||||||
|
let mut relays = strings("fn");
|
||||||
|
if relays.is_empty() {
|
||||||
|
relays = vec![
|
||||||
|
format!("{fn_id}.fnos.net"),
|
||||||
|
format!("{fn_id}.5ddd.com"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
for addr in relays {
|
||||||
|
let domain = strip_port(&addr);
|
||||||
|
if !domain.is_empty() {
|
||||||
|
out.push((format!("https://{domain}"), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测单个 base_url 是否可用(中继候选携带 `mode=relay` 才会被网关转发)。
|
||||||
|
///
|
||||||
|
/// 判据必须排除 3xx:重定向说明请求**没有真正落到 NAS 音乐后端**
|
||||||
|
/// (中继缺 `mode=relay` 时网关 302 回登录页;端口上实际是 fnOS Web UI 时同样 302)。
|
||||||
|
/// 早先把「任何 < 500」都当可达,结果候选探测通过、紧接着登录报 `HTTP 302 Found`。
|
||||||
|
async fn probe(client: &reqwest::Client, url: &str, relay: bool) -> bool {
|
||||||
|
let timeout = if relay {
|
||||||
|
FN_PROBE_TIMEOUT_RELAY
|
||||||
|
} else {
|
||||||
|
FN_PROBE_TIMEOUT_DIRECT
|
||||||
|
};
|
||||||
|
let full = format!(
|
||||||
|
"{}/music/api/v1/track/list?page=1&size=1",
|
||||||
|
url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let mut rb = client.get(&full).timeout(timeout);
|
||||||
|
if relay {
|
||||||
|
rb = rb.header("cookie", "mode=relay");
|
||||||
|
}
|
||||||
|
match rb.send().await {
|
||||||
|
// 未登录时中继会返回 401(已触达 NAS 音乐后端),直连返回 200/401,
|
||||||
|
// 均视为链路可达;3xx(重定向)与 5xx 视为不可达。
|
||||||
|
Ok(resp) => {
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
code < 500 && !(300..400).contains(&code)
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 fnId → 首个可达的 base_url;返回 `(base_url, is_relay)`。
|
||||||
|
///
|
||||||
|
/// 所有候选并发探测,按优先级(索引越小越优先)取首个确认可达者并早停,
|
||||||
|
/// 避免远程场景下顺序探测逐个等待超时。
|
||||||
|
pub async fn resolve_base_url(fn_id: &str) -> Result<(String, bool), String> {
|
||||||
|
let data = query_fn_connect(fn_id).await?;
|
||||||
|
let candidates = build_candidates(&data, fn_id);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Err("FnConnect 未返回可用地址".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(FN_PROBE_TIMEOUT_RELAY)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut pending = FuturesUnordered::new();
|
||||||
|
for (i, (url, relay)) in candidates.iter().enumerate() {
|
||||||
|
let client = client.clone();
|
||||||
|
let url = url.clone();
|
||||||
|
let relay = *relay;
|
||||||
|
pending.push(async move {
|
||||||
|
let ok = probe(&client, &url, relay).await;
|
||||||
|
(i, url, relay, ok)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut undecided: Vec<usize> = (0..candidates.len()).collect();
|
||||||
|
let mut best: Option<(usize, String, bool)> = None;
|
||||||
|
|
||||||
|
while let Some((i, url, relay, ok)) = pending.next().await {
|
||||||
|
undecided.retain(|x| *x != i);
|
||||||
|
if ok && best.as_ref().map_or(true, |(bi, _, _)| i < *bi) {
|
||||||
|
best = Some((i, url, relay));
|
||||||
|
}
|
||||||
|
// 早停:已有可达候选,且不存在索引更小(优先级更高)的未决候选
|
||||||
|
if let Some((bi, _, _)) = best.as_ref() {
|
||||||
|
if !undecided.iter().any(|x| x < bi) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match best {
|
||||||
|
Some((idx, url, relay)) => {
|
||||||
|
let _ = idx;
|
||||||
|
Ok((url, relay))
|
||||||
|
}
|
||||||
|
None => Err(format!("FnConnect 候选均不可达({} 个)", candidates.len())),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
//! 飞牛音乐(NAS)原生接口客户端 + 本地流代理的运行期。
|
||||||
|
//!
|
||||||
|
//! 支持多连接(本地 / frp / 预留 FnConnect),当前以"激活连接"为准。
|
||||||
|
//! 原生接口路径/认证(`music-token` Cookie、`x-access-code` 安全码、`/user/password-login` 登录)
|
||||||
|
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
||||||
|
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
|
use lofty::tag::Accessor;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::MusicSettings;
|
||||||
|
|
||||||
|
pub use conn::normalize_base_url;
|
||||||
|
mod cache;
|
||||||
|
mod conn;
|
||||||
|
mod fnconnect;
|
||||||
|
pub mod proxy;
|
||||||
|
pub mod webdav;
|
||||||
|
|
||||||
|
pub use cache::CacheManager;
|
||||||
|
pub use fnconnect::{extract_fn_id, resolve_base_url};
|
||||||
|
|
||||||
|
use conn::{generate_device_id, sha256_hex};
|
||||||
|
use proxy::{ProxyCfg, ProxyShared};
|
||||||
|
|
||||||
|
/// 一条飞牛音乐连接(持久化在 `MusicSettings`)。
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FeiniuConnection {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// "lan" | "frp" | "fnconnect"(fnconnect 预留)
|
||||||
|
pub kind: String,
|
||||||
|
/// 服务器地址(http://192.168.x.x:5666 或 https://域名)
|
||||||
|
pub base_url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub token: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub access_code: String,
|
||||||
|
/// https 遇到自签证书时忽略校验
|
||||||
|
pub insecure: bool,
|
||||||
|
/// fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id)
|
||||||
|
#[serde(default)]
|
||||||
|
pub fn_id: String,
|
||||||
|
/// 是否经由 FnConnect 中继链路(`<fnId>.fnos.net`)。
|
||||||
|
/// 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FeiniuConnection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
kind: "lan".to_string(),
|
||||||
|
base_url: String::new(),
|
||||||
|
username: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
device_id: String::new(),
|
||||||
|
access_code: String::new(),
|
||||||
|
insecure: false,
|
||||||
|
fn_id: String::new(),
|
||||||
|
relay: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前激活连接信息(运行期副本,与 `MusicSettings` 的激活连接一致)。
|
||||||
|
struct Conn {
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
username: String,
|
||||||
|
device_id: String,
|
||||||
|
access_code: String,
|
||||||
|
insecure: bool,
|
||||||
|
/// FnConnect 中继链路标记
|
||||||
|
relay: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
||||||
|
struct ClientSlot {
|
||||||
|
insecure: bool,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建飞牛请求 client。
|
||||||
|
///
|
||||||
|
/// `streaming = true` 时不设**总超时**:reqwest 的 `timeout` 覆盖到响应体读完为止,
|
||||||
|
/// 用来取整首音频会把流式中途掐断(大文件/慢链路下播放器会一直缓冲)。
|
||||||
|
/// 流式只限制建连时间。
|
||||||
|
fn build_client(insecure: bool, streaming: bool) -> reqwest::Client {
|
||||||
|
let mut b = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.danger_accept_invalid_certs(insecure);
|
||||||
|
if !streaming {
|
||||||
|
b = b.timeout(Duration::from_secs(10));
|
||||||
|
}
|
||||||
|
b.build().unwrap_or_else(|_| reqwest::Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Feiniu {
|
||||||
|
client: Mutex<Option<ClientSlot>>,
|
||||||
|
/// 流代理专用 client(无总超时)
|
||||||
|
stream_client: Mutex<Option<ClientSlot>>,
|
||||||
|
conn: Mutex<Conn>,
|
||||||
|
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
||||||
|
cache: CacheManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Feiniu {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Mutex::new(None),
|
||||||
|
stream_client: Mutex::new(None),
|
||||||
|
conn: Mutex::new(Conn {
|
||||||
|
base_url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
username: String::new(),
|
||||||
|
device_id: String::new(),
|
||||||
|
access_code: String::new(),
|
||||||
|
insecure: false,
|
||||||
|
relay: false,
|
||||||
|
}),
|
||||||
|
proxy: Mutex::new(None),
|
||||||
|
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 insecure 惰性构建 client(`streaming` 决定是否免总超时)。
|
||||||
|
fn slot_client(slot: &Mutex<Option<ClientSlot>>, insecure: bool, streaming: bool) -> reqwest::Client {
|
||||||
|
let mut g = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let hit = g.as_ref().map(|s| s.insecure == insecure).unwrap_or(false);
|
||||||
|
if !hit {
|
||||||
|
*g = Some(ClientSlot {
|
||||||
|
insecure,
|
||||||
|
client: build_client(insecure, streaming),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
g.as_ref().unwrap().client.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Feiniu {
|
||||||
|
/// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。
|
||||||
|
pub fn set_cache_root(&mut self, app_data_dir: &Path) {
|
||||||
|
self.cache = CacheManager::new(app_data_dir);
|
||||||
|
}
|
||||||
|
/// 本地曲库标签缓存文件路径({cache_root}/local-tags.json)。
|
||||||
|
pub fn local_tags_path(&self) -> PathBuf {
|
||||||
|
self.cache.local_tags_path()
|
||||||
|
}
|
||||||
|
/// 取(并惰性构建)对应 insecure 的 reqwest client(普通请求,10s 总超时)。
|
||||||
|
fn client(&self, insecure: bool) -> reqwest::Client {
|
||||||
|
slot_client(&self.client, insecure, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取流式请求专用 client(无总超时)。
|
||||||
|
fn stream_client(&self, insecure: bool) -> reqwest::Client {
|
||||||
|
slot_client(&self.stream_client, insecure, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。
|
||||||
|
pub fn sync_with_settings(&self, s: &MusicSettings) {
|
||||||
|
let (base_url, token, username, device_id, access_code, insecure, relay) =
|
||||||
|
match s.feiniu_active() {
|
||||||
|
Some(c) => (
|
||||||
|
c.base_url.clone(),
|
||||||
|
// token 常态存放在系统凭据管理器(见 crate::music::secrets),
|
||||||
|
// 结构体字段为空;只有凭据库不可用(降级)时才回落到明文。
|
||||||
|
if c.token.is_empty() {
|
||||||
|
super::secrets::read_feiniu_token(&c.id)
|
||||||
|
} else {
|
||||||
|
c.token.clone()
|
||||||
|
},
|
||||||
|
c.username.clone(),
|
||||||
|
c.device_id.clone(),
|
||||||
|
c.access_code.clone(),
|
||||||
|
c.insecure,
|
||||||
|
c.relay,
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if let Ok(mut c) = self.conn.lock() {
|
||||||
|
c.base_url = base_url;
|
||||||
|
c.token = token;
|
||||||
|
c.username = username;
|
||||||
|
c.device_id = device_id;
|
||||||
|
c.access_code = access_code;
|
||||||
|
c.insecure = insecure;
|
||||||
|
c.relay = relay;
|
||||||
|
}
|
||||||
|
// 确保 client 构建到位(insecure 变化时重建)
|
||||||
|
self.client(insecure);
|
||||||
|
self.sync_proxy_cfg();
|
||||||
|
self.sync_proxy_client();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_proxy_cfg(&self) {
|
||||||
|
if let Ok(g) = self.proxy.lock() {
|
||||||
|
if let Some((_, shared)) = g.as_ref() {
|
||||||
|
let cfg = self.current_cfg();
|
||||||
|
if let Ok(mut c) = shared.cfg.lock() {
|
||||||
|
*c = cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_proxy_client(&self) {
|
||||||
|
// 代理必须用流式 client:普通 client 的 10s 总超时会把长音频流掐断
|
||||||
|
let (_, client) = self.conn_stream_client();
|
||||||
|
if let Ok(mut g) = self.proxy.lock() {
|
||||||
|
if let Some((_, shared)) = g.as_mut() {
|
||||||
|
shared.client = client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conn_client(&self) -> (bool, reqwest::Client) {
|
||||||
|
let insecure = self.conn.lock().unwrap_or_else(|e| e.into_inner()).insecure;
|
||||||
|
(insecure, self.client(insecure))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conn_stream_client(&self) -> (bool, reqwest::Client) {
|
||||||
|
let insecure = self.conn.lock().unwrap_or_else(|e| e.into_inner()).insecure;
|
||||||
|
(insecure, self.stream_client(insecure))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_cfg(&self) -> ProxyCfg {
|
||||||
|
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
ProxyCfg {
|
||||||
|
base_url: c.base_url.clone(),
|
||||||
|
token: c.token.clone(),
|
||||||
|
access_code: c.access_code.clone(),
|
||||||
|
relay: c.relay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造鉴权 Cookie 头。
|
||||||
|
///
|
||||||
|
/// 中继链路需把 `mode=relay` 与 `music-token` 合并进**同一个** Cookie 头
|
||||||
|
/// (拆成两个 Cookie 头会互相覆盖);未登录的中继请求只带 `mode=relay`。
|
||||||
|
/// 无 token 且非中继时返回 None,表示无需携带 Cookie。
|
||||||
|
fn auth_cookie(token: &str, relay: bool) -> Option<String> {
|
||||||
|
match (token.is_empty(), relay) {
|
||||||
|
(false, true) => Some(format!("music-token={token}; mode=relay")),
|
||||||
|
(false, false) => Some(format!("music-token={token}")),
|
||||||
|
(true, true) => Some("mode=relay".to_string()),
|
||||||
|
(true, false) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 鉴权上下文:`(base_url, token, access_code, relay)`。
|
||||||
|
fn auth_triple(&self) -> (String, String, String, bool) {
|
||||||
|
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
(
|
||||||
|
c.base_url.clone(),
|
||||||
|
c.token.clone(),
|
||||||
|
c.access_code.clone(),
|
||||||
|
c.relay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对某个 base_url 执行登录(探测/登录连接共用)。
|
||||||
|
/// 成功返回 `(userToken, device_id)` 并更新运行期 conn;命令层负责落回对应连接持久化。
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
|
let base = normalize_base_url(base_url);
|
||||||
|
let (device_id, insecure) = {
|
||||||
|
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let d = if c.device_id.is_empty() {
|
||||||
|
generate_device_id()
|
||||||
|
} else {
|
||||||
|
c.device_id.clone()
|
||||||
|
};
|
||||||
|
(d, c.insecure)
|
||||||
|
};
|
||||||
|
let body = json!({
|
||||||
|
"username": username,
|
||||||
|
"password": sha256_hex(password),
|
||||||
|
"deviceId": device_id,
|
||||||
|
});
|
||||||
|
let relay = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.relay;
|
||||||
|
let mut rb = self
|
||||||
|
.client(insecure)
|
||||||
|
.post(format!("{base}/music/api/v1/user/password-login"))
|
||||||
|
.json(&body);
|
||||||
|
// 中继链路:登录请求也需带 mode=relay,否则网关 302 回登录页
|
||||||
|
if let Some(cookie) = Self::auth_cookie("", relay) {
|
||||||
|
rb = rb.header("cookie", cookie);
|
||||||
|
}
|
||||||
|
let resp = rb
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let kind = if e.is_timeout() {
|
||||||
|
"连接超时(NAS 不可达?)"
|
||||||
|
} else {
|
||||||
|
"连接失败"
|
||||||
|
};
|
||||||
|
format!("{kind}: {e}")
|
||||||
|
})?;
|
||||||
|
let status = resp.status();
|
||||||
|
let b: Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| json!({ "code": status.as_u16() }));
|
||||||
|
let code = b["code"].as_i64().unwrap_or(i64::from(status.as_u16()));
|
||||||
|
if code != 0 {
|
||||||
|
if code == 120001 {
|
||||||
|
return Err("用户名或密码错误".into());
|
||||||
|
}
|
||||||
|
let msg = b["msg"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
let token = b["data"]["userToken"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| "登录失败:未返回 token".to_string())?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Ok(mut c) = self.conn.lock() {
|
||||||
|
c.base_url = base;
|
||||||
|
c.token = token.clone();
|
||||||
|
c.username = username.to_string();
|
||||||
|
c.device_id = device_id.clone();
|
||||||
|
}
|
||||||
|
self.sync_proxy_cfg();
|
||||||
|
Ok((token, device_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出:仅清运行期 token(保留连接信息与账号)。
|
||||||
|
pub fn logout(&self) {
|
||||||
|
if let Ok(mut c) = self.conn.lock() {
|
||||||
|
c.token.clear();
|
||||||
|
}
|
||||||
|
self.sync_proxy_cfg();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前激活连接配置(前端状态展示 + 是否已登录)。
|
||||||
|
pub fn config(&self) -> Value {
|
||||||
|
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
json!({
|
||||||
|
"baseUrl": c.base_url,
|
||||||
|
"username": c.username,
|
||||||
|
"loggedIn": !c.token.is_empty(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页拉取曲目列表:`GET /music/api/v1/track/list`(可选关键词)。
|
||||||
|
pub async fn list_tracks(&self, page: u32, size: u32, keyword: Option<&str>) -> Result<Value, String> {
|
||||||
|
let mut query = vec![
|
||||||
|
("page".to_string(), page.to_string()),
|
||||||
|
("size".to_string(), size.to_string()),
|
||||||
|
];
|
||||||
|
let kw = keyword.unwrap_or("").trim().to_string();
|
||||||
|
if !kw.is_empty() {
|
||||||
|
query.push(("keyword".to_string(), kw));
|
||||||
|
}
|
||||||
|
self.authed_get("/music/api/v1/track/list", query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 歌词:`GET /music/api/v1/lyric/list?trackGUID=<guid>`。
|
||||||
|
pub async fn lyric(&self, guid: &str) -> Result<String, String> {
|
||||||
|
let v = self
|
||||||
|
.authed_get(
|
||||||
|
"/music/api/v1/lyric/list",
|
||||||
|
vec![("trackGUID".to_string(), guid.to_string())],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(extract_lyric_text(&v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地媒体地址前缀:`http://127.0.0.1:<port>/feiniu`。首次调用惰性启动代理。
|
||||||
|
pub async fn media_prefix(&self) -> Result<String, String> {
|
||||||
|
let port = self.ensure_proxy().await?;
|
||||||
|
Ok(format!("http://127.0.0.1:{port}/feiniu"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// 缓存状态。
|
||||||
|
pub fn cache_status(&self) -> Value {
|
||||||
|
self.cache.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空缓存。
|
||||||
|
pub fn cache_clear(&self) {
|
||||||
|
self.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 命中缓存直接返回文件路径;未命中则从 NAS 流式拉取写入缓存后返回。
|
||||||
|
/// 返回缓存文件路径。失败返回 Err。
|
||||||
|
pub async fn cache_fetch(&self, guid: &str, max_gb: u32) -> Result<Option<String>, String> {
|
||||||
|
if let Some(hit) = self.cache.hit(guid) {
|
||||||
|
return Ok(Some(hit));
|
||||||
|
}
|
||||||
|
let (base, token, access_code, relay) = self.auth_triple();
|
||||||
|
if base.is_empty() || token.is_empty() {
|
||||||
|
return Err("未登录".into());
|
||||||
|
}
|
||||||
|
// 整首拉取写入缓存:同样用无总超时的 client,否则大文件会被 10s 超时截断
|
||||||
|
let (_, client) = self.conn_stream_client();
|
||||||
|
let mut rb = client.get(format!("{base}/music/api/v1/track/stream?guid={guid}"));
|
||||||
|
if let Some(cookie) = Self::auth_cookie(&token, relay) {
|
||||||
|
rb = rb.header("cookie", cookie);
|
||||||
|
}
|
||||||
|
if !access_code.is_empty() {
|
||||||
|
use base64::Engine;
|
||||||
|
rb = rb
|
||||||
|
.header(
|
||||||
|
"x-access-code",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()),
|
||||||
|
)
|
||||||
|
.header("x-access-source", "app");
|
||||||
|
}
|
||||||
|
let resp = rb.send().await.map_err(|e| format!("拉取失败: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("拉取失败(HTTP {})", resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let ct = resp
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ext = match ct.split('/').last() {
|
||||||
|
Some("flac") => "flac",
|
||||||
|
Some("mpeg") => "mp3",
|
||||||
|
Some("wav") => "wav",
|
||||||
|
Some("ogg") => "ogg",
|
||||||
|
Some("mp4") => "m4a",
|
||||||
|
Some("aac") => "aac",
|
||||||
|
_ => "bin",
|
||||||
|
};
|
||||||
|
let stream = resp.bytes_stream();
|
||||||
|
let path = self
|
||||||
|
.cache
|
||||||
|
.put(guid, ext, max_gb, stream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e)?;
|
||||||
|
let _ = path;
|
||||||
|
Ok(self.cache.hit(guid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== WebDAV 传输(下载到飞牛 / 曲库增删)=====
|
||||||
|
// 无状态:配置由前端每次调用传入,实现在 webdav 模块,命令层直接调用。
|
||||||
|
|
||||||
|
async fn ensure_proxy(&self) -> Result<u16, String> {
|
||||||
|
if let Ok(g) = self.proxy.lock() {
|
||||||
|
if let Some((port, _)) = g.as_ref() {
|
||||||
|
return Ok(*port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 流代理用无总超时的 client(见 build_client)
|
||||||
|
let (_, client) = self.conn_stream_client();
|
||||||
|
let shared = ProxyShared {
|
||||||
|
client,
|
||||||
|
cfg: Arc::new(Mutex::new(self.current_cfg())),
|
||||||
|
};
|
||||||
|
let port = proxy::start(shared.clone()).await?;
|
||||||
|
if let Ok(mut g) = self.proxy.lock() {
|
||||||
|
*g = Some((port, shared));
|
||||||
|
}
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authed_get(&self, path: &str, query: Vec<(String, String)>) -> Result<Value, String> {
|
||||||
|
let (base, token, access_code, relay) = self.auth_triple();
|
||||||
|
if base.is_empty() || token.is_empty() {
|
||||||
|
return Err("未登录".into());
|
||||||
|
}
|
||||||
|
let (_, client) = self.conn_client();
|
||||||
|
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||||
|
let mut rb = client.get(format!("{base}{path}")).query(&qrefs);
|
||||||
|
if let Some(cookie) = Self::auth_cookie(&token, relay) {
|
||||||
|
rb = rb.header("cookie", cookie);
|
||||||
|
}
|
||||||
|
if !access_code.is_empty() {
|
||||||
|
use base64::Engine;
|
||||||
|
rb = rb
|
||||||
|
.header(
|
||||||
|
"x-access-code",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()),
|
||||||
|
)
|
||||||
|
.header("x-access-source", "app");
|
||||||
|
}
|
||||||
|
let resp = rb.send().await.map_err(|e| format!("请求失败: {e}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body: Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| json!({ "code": status.as_u16() }));
|
||||||
|
let code = body["code"].as_i64().unwrap_or(i64::from(status.as_u16()));
|
||||||
|
if code != 0 {
|
||||||
|
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||||
|
return Err("登录已过期,请重新登录".into());
|
||||||
|
}
|
||||||
|
let msg = body["msg"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("请求失败(HTTP {status})"));
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
Ok(body["data"].clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地曲库 / 下载目录里认可的音频扩展名。
|
||||||
|
/// 扫描(递归)与单目录列举共用同一份,避免两处判定不一致。
|
||||||
|
pub const AUDIO_EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"];
|
||||||
|
|
||||||
|
/// 该路径是否为认可的音频文件(只看扩展名,不校验存在性)。
|
||||||
|
pub fn is_audio_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| AUDIO_EXTS.contains(&e.to_lowercase().as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出一个**目录**(非递归)下的音频文件:`{ items: [{path,name,size,mtim}] }`。
|
||||||
|
///
|
||||||
|
/// 与 [`scan_local_dirs`] 的分工:那个是「曲库全量扫描 + 标签解析」,
|
||||||
|
/// 用于曲库视图;这个是「只看一层目录、不读标签」的轻量列举,
|
||||||
|
/// 用于上传编排(只需在下载目录里找到刚落盘的文件)。
|
||||||
|
/// 为一次上传去递归遍历整个曲库目录是纯浪费。
|
||||||
|
pub fn list_audio_files(dir: &str) -> Value {
|
||||||
|
let mut items: Vec<Value> = Vec::new();
|
||||||
|
let p = Path::new(dir);
|
||||||
|
if !p.is_dir() {
|
||||||
|
return json!({ "items": items });
|
||||||
|
}
|
||||||
|
let Ok(entries) = fs::read_dir(p) else {
|
||||||
|
return json!({ "items": items });
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() || !is_audio_path(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
let mtim = entry
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("未知")
|
||||||
|
.to_string();
|
||||||
|
items.push(json!({
|
||||||
|
"path": path.to_string_lossy(),
|
||||||
|
"name": name,
|
||||||
|
"size": size,
|
||||||
|
"mtim": mtim,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
json!({ "items": items })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归扫描本地曲库目录中的音频文件,返回轻量条目。
|
||||||
|
///
|
||||||
|
/// 元数据(标题/歌手/专辑/时长/是否有内嵌封面)用 lofty 解析音频标签;
|
||||||
|
/// 结果按 path 缓存(size+mtim 未变即复用),避免万级曲库每次全量重解析。
|
||||||
|
/// `tags_cache_path` 为标签缓存文件路径(由命令层传入 CacheManager)。
|
||||||
|
///
|
||||||
|
/// **纯函数(不依赖 &self)**:全量解析是重活,命令层必须把它放进
|
||||||
|
/// `spawn_blocking`(Tauri 的同步命令在主线程执行,会冻结 UI)。
|
||||||
|
pub fn scan_local_dirs(dirs: &[String], tags_cache_path: Option<&Path>) -> Value {
|
||||||
|
// ---- 标签缓存 ----
|
||||||
|
let mut tag_cache: serde_json::Map<String, Value> = tags_cache_path
|
||||||
|
.and_then(|p| fs::read_to_string(p).ok())
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let cache_hit = |cache: &serde_json::Map<String, Value>,
|
||||||
|
path: &str,
|
||||||
|
size: u64,
|
||||||
|
mtim: u64|
|
||||||
|
-> Option<Value> {
|
||||||
|
cache
|
||||||
|
.get(path)
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.filter(|e| {
|
||||||
|
e.get("size").and_then(|x| x.as_u64()) == Some(size)
|
||||||
|
&& e.get("mtim").and_then(|x| x.as_u64()) == Some(mtim)
|
||||||
|
})
|
||||||
|
.map(|e| Value::Object(e.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items: Vec<Value> = Vec::new();
|
||||||
|
// 本次扫描命中的音频路径:用于修剪缓存(见函数末尾)
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
// 至少有一个目录可访问才允许修剪,避免目录临时不可用(外接盘未挂载)时清空整个缓存
|
||||||
|
let mut scanned_any_dir = false;
|
||||||
|
for dir in dirs {
|
||||||
|
let p = Path::new(dir);
|
||||||
|
if !p.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scanned_any_dir = true;
|
||||||
|
for entry in walkdir::WalkDir::new(p).follow_links(false) {
|
||||||
|
let Ok(entry) = entry else { continue };
|
||||||
|
if !entry.file_type().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if !is_audio_path(path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
let mtim = entry
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("未知")
|
||||||
|
.to_string();
|
||||||
|
seen.insert(path_str.clone());
|
||||||
|
|
||||||
|
// 标签:命中缓存直接复用;否则 lofty 解析并写回缓存
|
||||||
|
let tags = match cache_hit(&tag_cache, &path_str, size, mtim) {
|
||||||
|
Some(hit) => hit,
|
||||||
|
None => {
|
||||||
|
let mut t = json!({
|
||||||
|
"size": size,
|
||||||
|
"mtim": mtim,
|
||||||
|
"title": name,
|
||||||
|
"artist": "",
|
||||||
|
"album": "",
|
||||||
|
"durationS": 0,
|
||||||
|
"cover": false,
|
||||||
|
});
|
||||||
|
if let Ok(tagged) = lofty::read_from_path(path) {
|
||||||
|
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
|
||||||
|
if let Some(tag) = tag {
|
||||||
|
if let Some(v) = tag.title().filter(|s| !s.trim().is_empty()) {
|
||||||
|
t["title"] = json!(v.trim());
|
||||||
|
}
|
||||||
|
if let Some(v) = tag.artist().filter(|s| !s.trim().is_empty()) {
|
||||||
|
t["artist"] = json!(v.trim());
|
||||||
|
}
|
||||||
|
if let Some(v) = tag.album().filter(|s| !s.trim().is_empty()) {
|
||||||
|
t["album"] = json!(v.trim());
|
||||||
|
}
|
||||||
|
t["cover"] = json!(!tag.pictures().is_empty());
|
||||||
|
}
|
||||||
|
let secs = tagged.properties().duration().as_secs();
|
||||||
|
if secs > 0 {
|
||||||
|
t["durationS"] = json!(secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_cache.insert(path_str.clone(), t.clone());
|
||||||
|
t
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(json!({
|
||||||
|
"path": path_str,
|
||||||
|
"name": name,
|
||||||
|
"title": tags.get("title").cloned().unwrap_or(json!(name)),
|
||||||
|
"artist": tags.get("artist").cloned().unwrap_or(json!("")),
|
||||||
|
"album": tags.get("album").cloned().unwrap_or(json!("")),
|
||||||
|
"durationS": tags.get("durationS").cloned().unwrap_or(json!(0)),
|
||||||
|
"cover": tags.get("cover").cloned().unwrap_or(json!(false)),
|
||||||
|
"size": size,
|
||||||
|
"mtim": mtim,
|
||||||
|
"dir": dir,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 修剪:删掉本次扫描中未再出现的缓存条目(文件已删除/改名/移出曲库目录)。
|
||||||
|
// 只增不减的缓存会随使用时间无限膨胀,且每次扫描都要整份读写。
|
||||||
|
if scanned_any_dir {
|
||||||
|
tag_cache.retain(|k, _| seen.contains(k));
|
||||||
|
}
|
||||||
|
if let Some(p) = tags_cache_path {
|
||||||
|
if let Ok(s) = serde_json::to_string(&tag_cache) {
|
||||||
|
fs::write(p, s).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({ "items": items })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尽力从 /lyric/list 响应中取第一段歌词文本(响应结构未文档化,做宽松映射)。
|
||||||
|
fn extract_lyric_text(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::Array(arr) => arr
|
||||||
|
.first()
|
||||||
|
.map(|it| {
|
||||||
|
it["content"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| it["lyric"].as_str())
|
||||||
|
.or_else(|| it["text"].as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Value::Object(_) => v["content"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| v["lyric"].as_str())
|
||||||
|
.or_else(|| v["text"].as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! 飞牛音乐本地流代理(axum)。
|
||||||
|
//!
|
||||||
|
//! 原生 `/track/stream`、`/static/cover` 需要 `Cookie: music-token=<token>`,而
|
||||||
|
//! WebView 的 `<audio>/<img>` 无法设置 Cookie。本模块绑定 `127.0.0.1:<动态端口>`,
|
||||||
|
//! 转发请求时注入 Cookie 与可选的安全码头,前端直接用本地地址播放/显示,天然支持 Range/seek。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use lofty::file::TaggedFileExt;
|
||||||
|
|
||||||
|
use super::conn::normalize_base_url;
|
||||||
|
|
||||||
|
/// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct ProxyCfg {
|
||||||
|
pub base_url: String,
|
||||||
|
pub token: String,
|
||||||
|
pub access_code: String,
|
||||||
|
/// FnConnect 中继链路:所有请求需携带 `Cookie: mode=relay`
|
||||||
|
pub relay: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProxyShared {
|
||||||
|
pub client: reqwest::Client,
|
||||||
|
pub cfg: Arc<Mutex<ProxyCfg>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动本地流代理,绑定到 `127.0.0.1:0`(动态空闲端口),返回实际端口。
|
||||||
|
/// 代理以独立 tokio 任务常驻应用存活期;token 变化经共享 `ProxyCfg` 即时生效,无需重启。
|
||||||
|
pub async fn start(shared: ProxyShared) -> Result<u16, String> {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("绑定本地端口失败: {e}"))?;
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.port();
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/feiniu/stream", get(proxy_stream))
|
||||||
|
.route("/feiniu/cover", get(proxy_cover))
|
||||||
|
.route("/feiniu/local-cover", get(proxy_local_cover))
|
||||||
|
.with_state(shared);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
crate::logger::log_error("music", &format!("飞牛流代理异常: {e}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地音频文件的内嵌封面:`GET /feiniu/local-cover?path=<绝对路径>`。
|
||||||
|
///
|
||||||
|
/// 供「本地曲库」列表显示封面(无需 NAS 登录)。只接受音频扩展名,且必须
|
||||||
|
/// 能被 lofty 解析出内嵌图片——非音频文件在这里必然 404,不构成任意文件读取。
|
||||||
|
/// 响应带 immutable 缓存头:path 不变时 WebView 直接复用。
|
||||||
|
async fn proxy_local_cover(Query(q): Query<HashMap<String, String>>) -> Response {
|
||||||
|
const AUDIO_EXTS: [&str; 7] = ["mp3", "flac", "wav", "m4a", "aac", "ogg", "ape"];
|
||||||
|
let Some(path) = q.get("path") else {
|
||||||
|
return (StatusCode::BAD_REQUEST, "missing path").into_response();
|
||||||
|
};
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
let ext_ok = p
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| AUDIO_EXTS.contains(&e.to_lowercase().as_str()))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ext_ok || !p.is_file() {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
let picture = lofty::read_from_path(p).ok().and_then(|tagged| {
|
||||||
|
tagged
|
||||||
|
.primary_tag()
|
||||||
|
.or_else(|| tagged.first_tag())
|
||||||
|
.and_then(|t| t.pictures().first())
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
let Some(pic) = picture else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
// lofty 0.22 的 mime_type 返回 Option<MimeType>
|
||||||
|
let mime = pic
|
||||||
|
.mime_type()
|
||||||
|
.map(|m| m.to_string())
|
||||||
|
.unwrap_or_else(|| "image/jpeg".to_string());
|
||||||
|
let mut resp = ([(header::CONTENT_TYPE, mime)], pic.data().to_vec()).into_response();
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
header::HeaderValue::from_static("public, max-age=2592000, immutable"),
|
||||||
|
);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_stream(
|
||||||
|
State(s): State<ProxyShared>,
|
||||||
|
Query(q): Query<HashMap<String, String>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let guid = q.get("guid").cloned().unwrap_or_default().trim().to_string();
|
||||||
|
if guid.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, "missing guid").into_response();
|
||||||
|
}
|
||||||
|
forward(
|
||||||
|
&s,
|
||||||
|
"/music/api/v1/track/stream",
|
||||||
|
vec![("guid".to_string(), guid)],
|
||||||
|
&headers,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_cover(
|
||||||
|
State(s): State<ProxyShared>,
|
||||||
|
Query(q): Query<HashMap<String, String>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let cover_id = q
|
||||||
|
.get("coverId")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if cover_id.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, "missing coverId").into_response();
|
||||||
|
}
|
||||||
|
let size = q.get("size").cloned().unwrap_or_else(|| "320".into());
|
||||||
|
forward(
|
||||||
|
&s,
|
||||||
|
"/music/api/v1/static/cover",
|
||||||
|
vec![
|
||||||
|
("coverId".to_string(), cover_id),
|
||||||
|
("size".to_string(), size),
|
||||||
|
],
|
||||||
|
&headers,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一转发:向 NAS 发起上游 GET,注入 Cookie/安全码,透传 Range 与响应头,流式回传 body。
|
||||||
|
async fn forward(
|
||||||
|
s: &ProxyShared,
|
||||||
|
path: &str,
|
||||||
|
query: Vec<(String, String)>,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let cfg = match s.cfg.lock() {
|
||||||
|
Ok(g) => g.clone(),
|
||||||
|
Err(e) => e.into_inner().clone(),
|
||||||
|
};
|
||||||
|
if cfg.base_url.is_empty() || cfg.token.is_empty() {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "飞牛音乐未登录").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = normalize_base_url(&cfg.base_url);
|
||||||
|
let url = format!("{base}{path}");
|
||||||
|
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||||
|
|
||||||
|
let mut rb = s.client.get(&url).query(&qrefs);
|
||||||
|
// 中继链路必须带 mode=relay,网关据此转发到 NAS(与 music-token 合并进同一个 Cookie 头)。
|
||||||
|
rb = rb.header(
|
||||||
|
"cookie",
|
||||||
|
if cfg.relay {
|
||||||
|
format!("music-token={}; mode=relay", cfg.token)
|
||||||
|
} else {
|
||||||
|
format!("music-token={}", cfg.token)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !cfg.access_code.is_empty() {
|
||||||
|
use base64::Engine;
|
||||||
|
rb = rb
|
||||||
|
.header(
|
||||||
|
"x-access-code",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(cfg.access_code.as_bytes()),
|
||||||
|
)
|
||||||
|
.header("x-access-source", "app");
|
||||||
|
}
|
||||||
|
if let Some(range) = headers.get(header::RANGE) {
|
||||||
|
rb = rb.header(header::RANGE, range.clone());
|
||||||
|
}
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
let resp = match rb.send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("上游错误: {e}")).into_response(),
|
||||||
|
};
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
|
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
|
||||||
|
let cl = resp.headers().get(header::CONTENT_LENGTH).cloned();
|
||||||
|
let cr = resp.headers().get(header::CONTENT_RANGE).cloned();
|
||||||
|
let ar = resp.headers().get(header::ACCEPT_RANGES).cloned();
|
||||||
|
// 缓存头必须透传:NAS 的封面接口带 `public, max-age=2592000, immutable`,
|
||||||
|
// 丢掉它就等于告诉 WebView「这个响应不可缓存」——每次进曲库页都要重新下载全部封面。
|
||||||
|
let cc = resp.headers().get(header::CACHE_CONTROL).cloned();
|
||||||
|
let et = resp.headers().get(header::ETAG).cloned();
|
||||||
|
let lm = resp.headers().get(header::LAST_MODIFIED).cloned();
|
||||||
|
let ex = resp.headers().get(header::EXPIRES).cloned();
|
||||||
|
|
||||||
|
let body = axum::body::Body::from_stream(resp.bytes_stream());
|
||||||
|
let mut out = Response::new(body);
|
||||||
|
*out.status_mut() = status;
|
||||||
|
let h = out.headers_mut();
|
||||||
|
if let Some(v) = ct {
|
||||||
|
h.insert(header::CONTENT_TYPE, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = cl {
|
||||||
|
h.insert(header::CONTENT_LENGTH, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = cr {
|
||||||
|
h.insert(header::CONTENT_RANGE, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = ar {
|
||||||
|
h.insert(header::ACCEPT_RANGES, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = cc {
|
||||||
|
h.insert(header::CACHE_CONTROL, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = et {
|
||||||
|
h.insert(header::ETAG, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = lm {
|
||||||
|
h.insert(header::LAST_MODIFIED, v);
|
||||||
|
}
|
||||||
|
if let Some(v) = ex {
|
||||||
|
h.insert(header::EXPIRES, v);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! WebDAV 客户端(无状态)——用于把音乐传输到 fnOS NAS 的曲库目录。
|
||||||
|
//!
|
||||||
|
//! 选择 WebDAV 而非 SMB/FTP/NFS 的原因:
|
||||||
|
//! - 纯 HTTP 语义,`reqwest` 即可实现,无原生依赖(SMB 在 Rust 侧生态差);
|
||||||
|
//! - **无会话**:Basic 认证随每个请求携带,没有"重启后要重新登录"的问题;
|
||||||
|
//! - fnOS 原生支持:系统设置 → 文件服务 → WebDAV(HTTP 5005 / HTTPS 5006)。
|
||||||
|
//!
|
||||||
|
//! fnOS 侧需要的设置(作为 UI 提示展示):
|
||||||
|
//! 1. 开启 WebDAV 服务并记下端口;
|
||||||
|
//! 2. 目标文件夹必须在「可见文件夹范围」内(团队文件夹需勾选
|
||||||
|
//! 「允许通过文件共享协议(SMB、WebDAV、FTP)挂载到其他设备上」);
|
||||||
|
//! 3. 账号对该文件夹有读写权限。
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// WebDAV 连接配置(由前端每次调用时传入,后端不持有状态)。
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||||
|
pub struct WebDavConfig {
|
||||||
|
/// 服务地址,如 `http://192.168.110.100:5005`
|
||||||
|
pub url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebDAV 操作结果状态码相关的可读错误。
|
||||||
|
fn status_err(action: &str, status: u16, url: &str) -> String {
|
||||||
|
match status {
|
||||||
|
401 => format!("WebDAV 认证失败(401):请检查账号与密码"),
|
||||||
|
403 => format!("WebDAV 拒绝访问(403):账号可能没有该目录的写入权限"),
|
||||||
|
404 => format!("WebDAV 目标不存在(404):{url}(目录需要先在 NAS 上创建,且在 WebDAV 可见范围内)"),
|
||||||
|
409 => format!("WebDAV 父目录不存在(409):{url}"),
|
||||||
|
_ => format!("WebDAV {action}失败(HTTP {status}):{url}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dav_client(_cfg: &WebDavConfig) -> Result<reqwest::Client, String> {
|
||||||
|
// NAS 通常在内网,默认禁用系统代理(走代理会连不上);Basic 认证在请求级设置
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(600))
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拼接 WebDAV 完整 URL:base + 远程路径(逐段百分号编码,保留 `/`)。
|
||||||
|
fn dav_url(cfg: &WebDavConfig, remote_path: &str) -> String {
|
||||||
|
let base = cfg.url.trim_end_matches('/');
|
||||||
|
let path = remote_path.trim_start_matches('/');
|
||||||
|
let encoded: Vec<String> = path
|
||||||
|
.split('/')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|seg| {
|
||||||
|
let mut out = String::new();
|
||||||
|
for b in seg.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("{base}/{}", encoded.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_auth(mut rb: reqwest::RequestBuilder, cfg: &WebDavConfig) -> reqwest::RequestBuilder {
|
||||||
|
if !cfg.username.is_empty() {
|
||||||
|
rb = rb.basic_auth(&cfg.username, Some(&cfg.password));
|
||||||
|
}
|
||||||
|
rb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐级 MKCOL 创建远程目录(已存在会被忽略)。
|
||||||
|
async fn ensure_dirs(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> {
|
||||||
|
let path = remote_path.trim_start_matches('/');
|
||||||
|
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
// 最后一段是文件名,只建目录部分
|
||||||
|
let dirs: Vec<String> = segs
|
||||||
|
.iter()
|
||||||
|
.take(segs.len().saturating_sub(1))
|
||||||
|
.fold(Vec::new(), |mut acc, s| {
|
||||||
|
let prev = acc.last().cloned().unwrap_or_default();
|
||||||
|
acc.push(if prev.is_empty() {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{prev}/{s}")
|
||||||
|
});
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
let client = dav_client(cfg)?;
|
||||||
|
for d in dirs {
|
||||||
|
let url = dav_url(cfg, &d);
|
||||||
|
let method = reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL 是合法的 HTTP 方法");
|
||||||
|
let resp = with_auth(client.request(method, &url), cfg)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("WebDAV MKCOL 失败: {e}"))?;
|
||||||
|
// 201 创建成功;405 已存在;其余只记不阻断(权限问题会在 PUT 时暴露)
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
if code != 201 && code != 405 && code != 301 && code != 409 && code != 200 {
|
||||||
|
return Err(status_err("创建目录", code, &url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试连接:PROPFIND 目标目录(Depth 0)。成功返回目录确认信息。
|
||||||
|
pub async fn test(cfg: &WebDavConfig, dir: &str) -> Result<String, String> {
|
||||||
|
let url = dav_url(cfg, dir);
|
||||||
|
let client = dav_client(cfg)?;
|
||||||
|
let resp = with_auth(
|
||||||
|
client
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||||
|
.header("Depth", "0")
|
||||||
|
.body(String::new()),
|
||||||
|
cfg,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("无法连接 WebDAV 服务:{e}(请确认地址与端口)"))?;
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
match code {
|
||||||
|
207 | 200 => Ok(format!("连接成功:{url}")),
|
||||||
|
404 => Err(format!(
|
||||||
|
"WebDAV 服务可达,但目标目录不存在(404):{url}\n请先在 NAS 上创建该目录,并确认它在 WebDAV「可见文件夹范围」内"
|
||||||
|
)),
|
||||||
|
401 => Err("WebDAV 认证失败(401):请检查账号与密码".to_string()),
|
||||||
|
403 => Err("WebDAV 拒绝访问(403):账号可能没有该目录的读取权限".to_string()),
|
||||||
|
_ => Err(status_err("连接", code, &url)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传本地文件(流式读取,不整载内存)。返回远程路径。
|
||||||
|
pub async fn upload_file(
|
||||||
|
cfg: &WebDavConfig,
|
||||||
|
local_path: &std::path::Path,
|
||||||
|
remote_path: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
ensure_dirs(cfg, remote_path).await?;
|
||||||
|
let file = tokio::fs::File::open(local_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("读取本地文件失败: {e}"))?;
|
||||||
|
// 128KB 分块流:用 unfold 把 AsyncRead 转 Stream,避免引入 tokio-util
|
||||||
|
let stream = futures_util::stream::unfold(file, |mut f| async move {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let mut buf = vec![0u8; 128 * 1024];
|
||||||
|
match f.read(&mut buf).await {
|
||||||
|
Ok(0) => None,
|
||||||
|
Ok(n) => Some((Ok::<Bytes, std::io::Error>(Bytes::copy_from_slice(&buf[..n])), f)),
|
||||||
|
Err(e) => Some((Err(e), f)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let url = dav_url(cfg, remote_path);
|
||||||
|
let client = dav_client(cfg)?;
|
||||||
|
let resp = with_auth(client.put(&url), cfg)
|
||||||
|
.header("Content-Type", mime_of(remote_path))
|
||||||
|
.body(reqwest::Body::wrap_stream(stream))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("WebDAV 上传失败: {e}"))?;
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
if !(200..300).contains(&code) {
|
||||||
|
return Err(status_err("上传", code, &url));
|
||||||
|
}
|
||||||
|
Ok(remote_path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除远程文件(404 视为已删除)。
|
||||||
|
pub async fn delete(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> {
|
||||||
|
let url = dav_url(cfg, remote_path);
|
||||||
|
let client = dav_client(cfg)?;
|
||||||
|
let resp = with_auth(client.delete(&url), cfg)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("WebDAV 删除失败: {e}"))?;
|
||||||
|
let code = resp.status().as_u16();
|
||||||
|
if code == 404 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !(200..300).contains(&code) {
|
||||||
|
return Err(status_err("删除", code, &url));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mime_of(name: &str) -> &'static str {
|
||||||
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"m4a" => "audio/mp4",
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"ogg" => "audio/ogg",
|
||||||
|
"ape" => "audio/x-ape",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
//! 音乐下载模块(musicdl 桥接)。
|
||||||
|
//!
|
||||||
|
//! 架构:Vue 前端 → Tauri 命令 → [MusicManager] → stdio JSON-Lines → `bridge.py`
|
||||||
|
//! → musicdl(纯 Python 聚合下载器)。
|
||||||
|
//!
|
||||||
|
//! 目录布局({app_data_dir}/music/):
|
||||||
|
//! - `bridge.py`:桥接脚本(include_str! 内置,运行时写出)
|
||||||
|
//! - `runtime/python/`:便携 Python(python.org embeddable,含 pip)
|
||||||
|
//! - `runtime/get-pip.py`:pip 引导脚本(下载后删除)
|
||||||
|
//! - `runtime/*.zip`:下载过程中的临时安装包(完成后删除)
|
||||||
|
//! - `outputs/`:默认音乐下载目录
|
||||||
|
//!
|
||||||
|
//! 子模块:
|
||||||
|
//! - [`runtime`]:Python 运行时探测与便携版安装
|
||||||
|
//! - [`bridge`]:桥接进程生命周期(spawn / JSON 协议 / ping / 停止)
|
||||||
|
//! - [`commands`]:Tauri 命令层
|
||||||
|
|
||||||
|
mod bridge;
|
||||||
|
mod commands;
|
||||||
|
mod feiniu;
|
||||||
|
mod runtime;
|
||||||
|
mod secrets;
|
||||||
|
|
||||||
|
pub use feiniu::{
|
||||||
|
extract_fn_id, normalize_base_url, resolve_base_url, scan_local_dirs, Feiniu, FeiniuConnection,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use commands::{
|
||||||
|
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
|
||||||
|
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
|
||||||
|
feiniu_list_connections, feiniu_list_audio_files, feiniu_list_tracks, feiniu_login,
|
||||||
|
feiniu_logout, feiniu_lyric,
|
||||||
|
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
|
||||||
|
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
|
||||||
|
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
|
||||||
|
music_resolve, music_save_settings, music_search, music_secret_get, music_secret_set,
|
||||||
|
music_stop_bridge, music_update_musicdl, webdav_delete, webdav_get_secret,
|
||||||
|
webdav_save_secret, webdav_test, webdav_upload,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, ChildStdin};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
/// 便携 Python 版本(python.org embeddable,含 pip 引导)
|
||||||
|
pub const BUNDLED_PY_VERSION: &str = "3.12.10";
|
||||||
|
/// musicdl 锁定版本(其 API 每周都在变,必须锁版本并定期升级)
|
||||||
|
pub const MUSICDL_VERSION: &str = "2.13.11";
|
||||||
|
/// pip 镜像源(国内网络直连 PyPI 较慢,默认用清华镜像,可改回官方)
|
||||||
|
pub const PIP_INDEX_URL: &str = "https://pypi.tuna.tsinghua.edu.cn/simple";
|
||||||
|
/// 默认搜索源(网易云 / QQ音乐 / 酷狗)
|
||||||
|
pub const DEFAULT_SOURCES: [&str; 3] = [
|
||||||
|
"NeteaseMusicClient",
|
||||||
|
"QQMusicClient",
|
||||||
|
"KugouMusicClient",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 桥接进程条目(自管 stdio,不走 ProcessManager)
|
||||||
|
pub(crate) struct BridgeEntry {
|
||||||
|
pub child: Child,
|
||||||
|
pub stdin: ChildStdin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析出的 Python 运行时信息(内部使用,不序列化)
|
||||||
|
pub(crate) struct PythonEnv {
|
||||||
|
pub source: &'static str, // "system" | "bundled"
|
||||||
|
pub exe: PathBuf,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 环境状态(返回前端,设置页「环境检查」面板展示)
|
||||||
|
#[derive(Serialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MusicEnvStatus {
|
||||||
|
/// 系统 Python 版本(如 "3.14.5"),无则 None
|
||||||
|
pub python: Option<String>,
|
||||||
|
/// python 来源:"system" | "bundled" | "none"
|
||||||
|
pub python_source: String,
|
||||||
|
/// 便携 Python 可执行文件路径(未安装则 None)
|
||||||
|
pub bundled_python: Option<String>,
|
||||||
|
/// musicdl 是否可导入
|
||||||
|
pub musicdl_installed: bool,
|
||||||
|
/// musicdl 版本
|
||||||
|
pub musicdl_version: Option<String>,
|
||||||
|
/// 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准
|
||||||
|
pub musicdl_expected: String,
|
||||||
|
/// 已装 musicdl 是否与锁定版本不一致。
|
||||||
|
/// 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。
|
||||||
|
pub musicdl_outdated: bool,
|
||||||
|
/// FFmpeg 是否可用(部分音源需要,非必需)
|
||||||
|
pub ffmpeg: Option<String>,
|
||||||
|
/// 桥接进程是否在运行
|
||||||
|
pub bridge_running: bool,
|
||||||
|
/// 运行时目录({app_data_dir}/music)
|
||||||
|
pub runtime_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行时安装进度事件负载(对应 events::MUSIC_RUNTIME_INSTALL_PROGRESS)
|
||||||
|
#[derive(Serialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MusicInstallProgress {
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u32,
|
||||||
|
pub downloaded_bytes: u64,
|
||||||
|
pub total_bytes: Option<u64>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音乐模块设置(settings.json 持久化;变更即时生效)
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MusicSettings {
|
||||||
|
/// 下载保存目录
|
||||||
|
pub savedir: String,
|
||||||
|
/// 搜索源(musicdl 客户端名,如 NeteaseMusicClient)
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
/// 下载时同步保存歌词
|
||||||
|
pub lyric_download: bool,
|
||||||
|
/// 下载时同步保存封面
|
||||||
|
pub cover_download: bool,
|
||||||
|
/// 搜索/下载请求是否走代理模块(mihomo mixed 端口)
|
||||||
|
pub use_proxy: bool,
|
||||||
|
/// 最大并发下载数
|
||||||
|
pub max_concurrent: u32,
|
||||||
|
/// 下载引擎:"musicdl" | "rust"(P2 生效)
|
||||||
|
pub download_engine: String,
|
||||||
|
/// 下载时是否弹窗选择音质(默认关;开启后点下载弹出所选歌曲档位并集选择)
|
||||||
|
pub select_quality_on_download: bool,
|
||||||
|
/// 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K")
|
||||||
|
pub default_download_quality: String,
|
||||||
|
/// QQ 音乐 Cookie(可选):用于解析需要登录的歌单(含自己的隐私歌单)与 VIP 音质。
|
||||||
|
/// 传给 musicdl 的 default_search/parse/download_cookies;空=游客身份。
|
||||||
|
#[serde(default)]
|
||||||
|
pub qq_cookie: String,
|
||||||
|
/// 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_base_url: String,
|
||||||
|
/// 飞牛音乐登录 token(登录成功后保存)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_token: String,
|
||||||
|
/// 飞牛音乐登录账号(展示 + 重新登录回填用)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_username: String,
|
||||||
|
/// 飞牛音乐设备 ID(32 位 hex,登录签名用,一次生成复用)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_device_id: String,
|
||||||
|
/// 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_access_code: String,
|
||||||
|
/// 飞牛音乐连接列表(多连接:局域网 / FnConnect)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_connections: Vec<FeiniuConnection>,
|
||||||
|
/// 当前激活连接的 id
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_active_id: String,
|
||||||
|
/// 本地曲库扫描目录(默认含音乐下载 savedir)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_local_dirs: Vec<String>,
|
||||||
|
/// 播放缓存开关
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_cache_enabled: bool,
|
||||||
|
/// 缓存上限(GB)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_cache_max_gb: u32,
|
||||||
|
/// 播放模式:"stream" 直连流式 | "cache" 缓存后播放
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_play_mode: String,
|
||||||
|
/// 飞牛曲库目标目录(NAS 绝对路径,如 vol1/1000/Music;上传到飞牛用)
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_library_nas_path: String,
|
||||||
|
/// 下载完成后自动上传到飞牛曲库
|
||||||
|
#[serde(default)]
|
||||||
|
pub feiniu_auto_upload: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicSettings {
|
||||||
|
/// 取激活连接:优先按 active_id,否则回退到第一条。
|
||||||
|
pub fn feiniu_active(&self) -> Option<&FeiniuConnection> {
|
||||||
|
self.feiniu_connections
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == self.feiniu_active_id)
|
||||||
|
.or_else(|| self.feiniu_connections.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 兼容旧版单连接字段:把旧字段迁移成列表里的一条连接,并**清除旧字段**。
|
||||||
|
///
|
||||||
|
/// 必须是「一次性」的:旧字段一旦残留,用户把连接删光后,下一次 `load_settings()`
|
||||||
|
/// 会再次命中「列表为空 + 旧字段非空」而重新造出一条连接,
|
||||||
|
/// 表现为「删掉的连接切个页又回来了」。因此迁移完成后要清空旧字段,
|
||||||
|
/// 且返回是否发生变更,由 `load_settings` 负责落盘。
|
||||||
|
pub fn migrate_feiniu(&mut self) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// 1) 旧单连接字段 → 连接列表(仅在列表为空时迁移)
|
||||||
|
if self.feiniu_connections.is_empty() && !self.feiniu_base_url.trim().is_empty() {
|
||||||
|
let base = self.feiniu_base_url.clone();
|
||||||
|
self.feiniu_connections.push(FeiniuConnection {
|
||||||
|
id: "default".to_string(),
|
||||||
|
name: base.clone(),
|
||||||
|
kind: "lan".to_string(),
|
||||||
|
base_url: base,
|
||||||
|
username: self.feiniu_username.clone(),
|
||||||
|
token: self.feiniu_token.clone(),
|
||||||
|
device_id: self.feiniu_device_id.clone(),
|
||||||
|
access_code: self.feiniu_access_code.clone(),
|
||||||
|
insecure: false,
|
||||||
|
fn_id: String::new(),
|
||||||
|
relay: false,
|
||||||
|
});
|
||||||
|
self.feiniu_active_id = "default".to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 无论列表是否为空,旧字段都已无意义(内容已并入连接,或本就没有连接),
|
||||||
|
// 一律清空并落盘,杜绝其再次触发迁移。
|
||||||
|
for v in [
|
||||||
|
&mut self.feiniu_base_url,
|
||||||
|
&mut self.feiniu_token,
|
||||||
|
&mut self.feiniu_username,
|
||||||
|
&mut self.feiniu_device_id,
|
||||||
|
&mut self.feiniu_access_code,
|
||||||
|
] {
|
||||||
|
if !v.is_empty() {
|
||||||
|
v.clear();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 激活 id 兜底(内存态修正,无需落盘)
|
||||||
|
if self.feiniu_active_id.is_empty()
|
||||||
|
|| !self.feiniu_connections.iter().any(|c| c.id == self.feiniu_active_id)
|
||||||
|
{
|
||||||
|
if let Some(c) = self.feiniu_connections.first() {
|
||||||
|
self.feiniu_active_id = c.id.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把明文凭据迁入系统凭据管理器,并从结构体里清掉(返回是否有变更)。
|
||||||
|
///
|
||||||
|
/// 与 `migrate_feiniu` 同一套「一次性迁移」原则:**凭据库写入成功才清明文**,
|
||||||
|
/// 并由 `load_settings` 落盘,避免每次读取反复尝试。
|
||||||
|
/// 写失败时保留明文——宁可姿态不一致,也不能让用户莫名掉登录态。
|
||||||
|
pub fn migrate_secrets(&mut self) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
for c in self.feiniu_connections.iter_mut() {
|
||||||
|
let token = c.token.trim().to_string();
|
||||||
|
if token.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = secrets::feiniu_token_key(&c.id);
|
||||||
|
// 凭据库已有值时不覆盖:它可能比 settings.json 里的明文更新
|
||||||
|
let stored = secrets::secret_read(&key).ok().flatten().unwrap_or_default();
|
||||||
|
if stored.is_empty() && !secrets::try_store(&key, &token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
c.token.clear();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// settings 内存缓存条目(短时复用,避免高频调用反复读盘)
|
||||||
|
struct SettingsCacheEntry {
|
||||||
|
read_at: Instant,
|
||||||
|
settings: MusicSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音乐模块管理器
|
||||||
|
pub struct MusicManager {
|
||||||
|
root: PathBuf,
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// 桥接进程(stdio 自管)
|
||||||
|
bridge: Mutex<Option<BridgeEntry>>,
|
||||||
|
/// 待响应请求表:id → oneshot(reader 线程按 id 分发)
|
||||||
|
pending: Arc<Mutex<HashMap<u64, tokio::sync::oneshot::Sender<serde_json::Value>>>>,
|
||||||
|
/// 请求 id 自增
|
||||||
|
next_id: AtomicU64,
|
||||||
|
/// 便携运行时安装/下载取消标志
|
||||||
|
runtime_cancel: Arc<AtomicBool>,
|
||||||
|
/// 正在执行的 pip/python 子进程 pid(取消时 taskkill)
|
||||||
|
install_pid: Arc<AtomicU64>,
|
||||||
|
/// 桥接启动互斥锁(防止并发 ensure_bridge 双重 spawn)
|
||||||
|
start_lock: Mutex<()>,
|
||||||
|
/// settings 内存缓存
|
||||||
|
settings_cache: Mutex<Option<SettingsCacheEntry>>,
|
||||||
|
/// 飞牛音乐(NAS)播放器运行期(连接 + 本地流代理)
|
||||||
|
feiniu: Feiniu,
|
||||||
|
/// AppHandle(桥接 reader 线程据此将事件转发给前端;setup 时设置)
|
||||||
|
app: Mutex<Option<AppHandle>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicManager {
|
||||||
|
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||||
|
let root = app_data_dir.join("music");
|
||||||
|
for d in ["runtime", "outputs"] {
|
||||||
|
fs::create_dir_all(root.join(d)).ok();
|
||||||
|
}
|
||||||
|
let mut feiniu = Feiniu::default();
|
||||||
|
feiniu.set_cache_root(&app_data_dir);
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
// 默认 30s 兜底超时;流式下载按块推进,不受此限制
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||||
|
bridge: Mutex::new(None),
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
runtime_cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
install_pid: Arc::new(AtomicU64::new(0)),
|
||||||
|
start_lock: Mutex::new(()),
|
||||||
|
settings_cache: Mutex::new(None),
|
||||||
|
feiniu,
|
||||||
|
app: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 AppHandle(setup 阶段调用;桥接 reader 线程据此转发事件到前端)
|
||||||
|
pub fn set_app(&self, app: AppHandle) {
|
||||||
|
if let Ok(mut guard) = self.app.lock() {
|
||||||
|
*guard = Some(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn app_handle(&self) -> Option<AppHandle> {
|
||||||
|
self.app.lock().ok().and_then(|g| g.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 设置 ----------
|
||||||
|
/// 默认保存目录:系统「下载」目录(不可用时退回 {root}/outputs)
|
||||||
|
fn default_savedir(&self) -> String {
|
||||||
|
dirs::download_dir()
|
||||||
|
.unwrap_or_else(|| self.outputs_dir())
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_settings(&self) -> MusicSettings {
|
||||||
|
MusicSettings {
|
||||||
|
savedir: self.default_savedir(),
|
||||||
|
sources: DEFAULT_SOURCES.iter().map(|s| s.to_string()).collect(),
|
||||||
|
lyric_download: true,
|
||||||
|
cover_download: true,
|
||||||
|
use_proxy: false,
|
||||||
|
max_concurrent: 4,
|
||||||
|
download_engine: "musicdl".to_string(),
|
||||||
|
select_quality_on_download: false,
|
||||||
|
default_download_quality: "最高".to_string(), // 默认下载最高音质
|
||||||
|
qq_cookie: String::new(),
|
||||||
|
feiniu_base_url: String::new(),
|
||||||
|
feiniu_token: String::new(),
|
||||||
|
feiniu_username: String::new(),
|
||||||
|
feiniu_device_id: String::new(),
|
||||||
|
feiniu_access_code: String::new(),
|
||||||
|
feiniu_connections: Vec::new(),
|
||||||
|
feiniu_active_id: String::new(),
|
||||||
|
feiniu_local_dirs: Vec::new(),
|
||||||
|
feiniu_cache_enabled: false,
|
||||||
|
feiniu_cache_max_gb: 5,
|
||||||
|
feiniu_play_mode: "stream".to_string(),
|
||||||
|
feiniu_library_nas_path: String::new(),
|
||||||
|
feiniu_auto_upload: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_path(&self) -> PathBuf {
|
||||||
|
self.root.join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取设置(500ms 内存缓存;文件缺失/损坏时回退默认值)
|
||||||
|
pub fn load_settings(&self) -> MusicSettings {
|
||||||
|
if let Ok(cache) = self.settings_cache.lock() {
|
||||||
|
if let Some(entry) = cache.as_ref() {
|
||||||
|
if entry.read_at.elapsed() < Duration::from_millis(500) {
|
||||||
|
return entry.settings.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let defaults = self.default_settings();
|
||||||
|
let settings = fs::read_to_string(self.settings_path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<MusicSettings>(&s).ok())
|
||||||
|
.unwrap_or_else(|| defaults.clone());
|
||||||
|
// 自愈:保存目录为空 / 源为空时补默认值
|
||||||
|
let mut settings = settings;
|
||||||
|
if settings.savedir.trim().is_empty() {
|
||||||
|
settings.savedir = defaults.savedir;
|
||||||
|
}
|
||||||
|
// 迁移:旧默认保存目录({root}/outputs)→ 系统下载目录
|
||||||
|
if settings.savedir == self.outputs_dir().to_string_lossy() {
|
||||||
|
settings.savedir = self.default_savedir();
|
||||||
|
}
|
||||||
|
if settings.sources.is_empty() {
|
||||||
|
settings.sources = defaults.sources;
|
||||||
|
}
|
||||||
|
// 飞牛音乐多连接迁移:旧单连接字段 → 连接列表,并清除旧字段。
|
||||||
|
// 变更必须落盘,否则每次读取都会重新迁移,导致删掉的连接"复活"。
|
||||||
|
let migrated = settings.migrate_feiniu();
|
||||||
|
// 明文凭据(连接 token)→ 系统凭据管理器,成功后从结构体清除。
|
||||||
|
let secrets_migrated = settings.migrate_secrets();
|
||||||
|
if let Ok(mut cache) = self.settings_cache.lock() {
|
||||||
|
*cache = Some(SettingsCacheEntry {
|
||||||
|
read_at: Instant::now(),
|
||||||
|
settings: settings.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if migrated || secrets_migrated {
|
||||||
|
if let Err(e) = self.save_settings(&settings) {
|
||||||
|
crate::logger::log_error("music", &format!("迁移飞牛连接设置落盘失败: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存设置并更新缓存
|
||||||
|
pub fn save_settings(&self, settings: &MusicSettings) -> Result<(), String> {
|
||||||
|
let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
|
||||||
|
fs::write(self.settings_path(), json).map_err(|e| format!("写入设置失败: {}", e))?;
|
||||||
|
if let Ok(mut cache) = self.settings_cache.lock() {
|
||||||
|
*cache = Some(SettingsCacheEntry {
|
||||||
|
read_at: Instant::now(),
|
||||||
|
settings: settings.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 目录 ----------
|
||||||
|
pub fn runtime_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("runtime")
|
||||||
|
}
|
||||||
|
/// 默认音乐下载目录
|
||||||
|
pub fn outputs_dir(&self) -> PathBuf {
|
||||||
|
self.root.join("outputs")
|
||||||
|
}
|
||||||
|
pub fn bridge_script_path(&self) -> PathBuf {
|
||||||
|
self.root.join("bridge.py")
|
||||||
|
}
|
||||||
|
pub fn bundled_python_exe(&self) -> PathBuf {
|
||||||
|
self.runtime_dir().join("python").join("python.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Python 探测 ----------
|
||||||
|
/// 解析桥接要用的 Python 运行时。
|
||||||
|
/// 便携版优先:musicdl 只装入便携运行时,系统 Python 无法保证装有 musicdl;
|
||||||
|
/// 无便携版时退回系统 Python(此时 search 会报 musicdl 未安装,引导用户装便携版)。
|
||||||
|
pub fn resolve_python(&self) -> Result<PythonEnv, String> {
|
||||||
|
if let Some(env) = self.detect_bundled_python() {
|
||||||
|
return Ok(env);
|
||||||
|
}
|
||||||
|
if let Some(env) = detect_system_python() {
|
||||||
|
return Ok(env);
|
||||||
|
}
|
||||||
|
Err("未找到 Python 运行时:系统未安装 Python,且便携版未安装。请点击「安装便携版」".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 便携 Python 是否可用(exe 存在且能跑 --version)
|
||||||
|
pub fn detect_bundled_python(&self) -> Option<PythonEnv> {
|
||||||
|
detect_bundled_python_at(&self.bundled_python_exe())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 环境状态 ----------
|
||||||
|
/// 查询环境状态。子进程探测(python/musicdl/ffmpeg,便携 Python 冷启动可达数秒)
|
||||||
|
/// 放入阻塞线程池执行,避免阻塞主线程导致 UI 冻结。
|
||||||
|
pub async fn env_status(&self) -> Result<MusicEnvStatus, String> {
|
||||||
|
let bundled_exe = self.bundled_python_exe();
|
||||||
|
let bridge_running = self.bridge.lock().map(|b| b.is_some()).unwrap_or(false);
|
||||||
|
let runtime_dir = self.root.to_string_lossy().to_string();
|
||||||
|
let probe =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || probe_env_blocking(bundled_exe))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("环境探测任务失败: {}", e))?;
|
||||||
|
Ok(MusicEnvStatus {
|
||||||
|
python: probe.python,
|
||||||
|
python_source: probe.python_source.to_string(),
|
||||||
|
bundled_python: probe.bundled_python,
|
||||||
|
musicdl_installed: probe.musicdl_installed,
|
||||||
|
musicdl_outdated: probe.musicdl_installed
|
||||||
|
&& !version_matches(probe.musicdl_version.as_deref(), MUSICDL_VERSION),
|
||||||
|
musicdl_version: probe.musicdl_version,
|
||||||
|
musicdl_expected: MUSICDL_VERSION.to_string(),
|
||||||
|
ffmpeg: probe.ffmpeg,
|
||||||
|
bridge_running,
|
||||||
|
runtime_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 退出清理 ----------
|
||||||
|
/// 应用退出时停止桥接进程(stdin/stdout 随 child drop 关闭,reader 线程读到 EOF 自行退出)
|
||||||
|
pub fn cleanup_on_exit(&self) {
|
||||||
|
self.stop_bridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 内部工具 ----------
|
||||||
|
pub(crate) fn cancel(&self) {
|
||||||
|
self.runtime_cancel.store(true, Ordering::SeqCst);
|
||||||
|
let pid = self.install_pid.load(Ordering::SeqCst);
|
||||||
|
if pid != 0 {
|
||||||
|
// 杀掉正在执行的 pip/python 子进程,避免安装流程挂住
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_cancelled(&self) -> bool {
|
||||||
|
self.runtime_cancel.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_lock(&self) -> &Mutex<()> {
|
||||||
|
&self.start_lock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 环境探测结果(spawn_blocking 跨线程返回)
|
||||||
|
struct EnvProbe {
|
||||||
|
python: Option<String>,
|
||||||
|
python_source: &'static str,
|
||||||
|
bundled_python: Option<String>,
|
||||||
|
musicdl_installed: bool,
|
||||||
|
musicdl_version: Option<String>,
|
||||||
|
ffmpeg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 阻塞式环境探测(串行 spawn 多个子进程,必须在阻塞线程池执行,禁止占用主线程)
|
||||||
|
fn probe_env_blocking(bundled_exe: PathBuf) -> EnvProbe {
|
||||||
|
let bundled = detect_bundled_python_at(&bundled_exe);
|
||||||
|
let sys_py = detect_system_python();
|
||||||
|
// 便携版优先(与 resolve_python 一致):musicdl 只装入便携运行时,
|
||||||
|
// 若按系统优先检查,装好便携版后 UI 仍会误报 musicdl 未安装
|
||||||
|
let python = bundled.as_ref().or(sys_py.as_ref());
|
||||||
|
|
||||||
|
let (musicdl_installed, musicdl_version) = match python {
|
||||||
|
Some(env) => check_musicdl(&env.exe),
|
||||||
|
None => (false, None),
|
||||||
|
};
|
||||||
|
EnvProbe {
|
||||||
|
python: python.map(|e| e.version.clone()),
|
||||||
|
python_source: match python {
|
||||||
|
Some(e) => e.source,
|
||||||
|
None => "none",
|
||||||
|
},
|
||||||
|
bundled_python: bundled.map(|e| e.exe.to_string_lossy().to_string()),
|
||||||
|
musicdl_installed,
|
||||||
|
musicdl_version,
|
||||||
|
ffmpeg: check_ffmpeg(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定路径的便携 Python 是否可用(exe 存在且能跑 --version)
|
||||||
|
fn detect_bundled_python_at(exe: &PathBuf) -> Option<PythonEnv> {
|
||||||
|
if !exe.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = run_python_version(exe)?;
|
||||||
|
Some(PythonEnv {
|
||||||
|
source: "bundled",
|
||||||
|
exe: exe.clone(),
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测系统 Python:依次尝试 python / py / python3,解析 `--version` 输出。
|
||||||
|
/// 注意:Windows 的「应用商店别名」python 会在无安装时打印提示并以非零码退出,会被自然过滤。
|
||||||
|
fn detect_system_python() -> Option<PythonEnv> {
|
||||||
|
for candidate in ["python", "py", "python3"] {
|
||||||
|
let mut cmd = std::process::Command::new(candidate);
|
||||||
|
cmd.arg("--version");
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
cmd.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
let out = cmd.output().ok()?;
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
if let Some(version) = parse_python_version(&text) {
|
||||||
|
return Some(PythonEnv {
|
||||||
|
source: "system",
|
||||||
|
exe: PathBuf::from(candidate),
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行 `python --version` 并解析版本号
|
||||||
|
fn run_python_version(exe: &PathBuf) -> Option<String> {
|
||||||
|
let mut cmd = std::process::Command::new(exe);
|
||||||
|
cmd.arg("--version");
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
cmd.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
let out = cmd.output().ok()?;
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
parse_python_version(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 "Python 3.14.5" 文本中提取 "3.14.5"
|
||||||
|
fn parse_python_version(text: &str) -> Option<String> {
|
||||||
|
text.split_whitespace().find_map(|w| {
|
||||||
|
let mut parts = w.split('.');
|
||||||
|
let major = parts.next()?.parse::<u32>().ok()?;
|
||||||
|
let minor = parts.next()?.parse::<u32>().ok()?;
|
||||||
|
if (major, minor) >= (3, 8) {
|
||||||
|
Some(w.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已装版本是否就是本应用锁定的版本。
|
||||||
|
///
|
||||||
|
/// `unknown` / 空值一律视为**不匹配**:`check_musicdl` 拿不到 `__version__` 时
|
||||||
|
/// 无法确认它是不是受支持的那一版,宁可提示更新。
|
||||||
|
/// 兼容 `v2.13.11` 这类带前缀的写法。
|
||||||
|
fn version_matches(installed: Option<&str>, expected: &str) -> bool {
|
||||||
|
let v = installed.unwrap_or("").trim().trim_start_matches('v');
|
||||||
|
!v.is_empty() && !v.eq_ignore_ascii_case("unknown") && v == expected
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查指定 Python 能否导入 musicdl(同步子进程调用,仅在设置页触发)
|
||||||
|
fn check_musicdl(exe: &PathBuf) -> (bool, Option<String>) {
|
||||||
|
let mut cmd = std::process::Command::new(exe);
|
||||||
|
cmd.args([
|
||||||
|
"-c",
|
||||||
|
"import musicdl; print(getattr(musicdl, '__version__', 'unknown'))",
|
||||||
|
]);
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
cmd.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
match cmd.output() {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
(true, Some(version))
|
||||||
|
}
|
||||||
|
_ => (false, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 FFmpeg 是否可用(部分海外音源需要,非必需)
|
||||||
|
fn check_ffmpeg() -> Option<String> {
|
||||||
|
let mut cmd = std::process::Command::new("ffmpeg");
|
||||||
|
cmd.arg("-version");
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
cmd.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
cmd.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| {
|
||||||
|
s.lines()
|
||||||
|
.next()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
//! 便携 Python 运行时安装:流式下载 → 解压 → _pth 补丁 → get-pip 引导 → musicdl 安装。
|
||||||
|
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||||||
|
//!
|
||||||
|
//! 依赖下载源(已验证):
|
||||||
|
//! - https://www.python.org/ftp/python/{ver}/python-{ver}-embed-amd64.zip
|
||||||
|
//! - https://bootstrap.pypa.io/get-pip.py
|
||||||
|
//!
|
||||||
|
//! 取消:`MusicManager::cancel()` 置标志 + taskkill 当前 pip/python 子进程。
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
use super::{MusicInstallProgress, MusicManager, PIP_INDEX_URL};
|
||||||
|
use crate::constants::events::MUSIC_RUNTIME_INSTALL_PROGRESS;
|
||||||
|
|
||||||
|
/// 用户主动取消安装的标记错误信息(前端据此静默处理)
|
||||||
|
pub(crate) const RUNTIME_CANCELLED: &str = "安装已取消";
|
||||||
|
|
||||||
|
impl MusicManager {
|
||||||
|
/// 安装便携 Python + musicdl(幂等:已就绪的步骤自动跳过),全程推送进度事件。
|
||||||
|
pub async fn install_runtime(&self, app: &AppHandle) -> Result<(), String> {
|
||||||
|
self.runtime_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let result = self.install_runtime_inner(app).await;
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
if e != RUNTIME_CANCELLED {
|
||||||
|
let _ = app.emit(
|
||||||
|
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||||
|
MusicInstallProgress {
|
||||||
|
stage: "error".into(),
|
||||||
|
percent: 0,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: e.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_runtime_inner(&self, app: &AppHandle) -> Result<(), String> {
|
||||||
|
let runtime_dir = self.runtime_dir();
|
||||||
|
fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let python_exe = self.bundled_python_exe();
|
||||||
|
|
||||||
|
// ---------- 1. 便携 Python 已就绪则跳过下载/解压 ----------
|
||||||
|
if !(python_exe.exists() && super::run_python_version(&python_exe).is_some()) {
|
||||||
|
self.emit_progress(app, "download", 2, 0, None, "开始下载便携 Python...").await;
|
||||||
|
|
||||||
|
// 下载 embeddable zip(~11MB,流式写入)
|
||||||
|
let zip_path = runtime_dir.join(format!("python-{}-embed.zip", super::BUNDLED_PY_VERSION));
|
||||||
|
let zip_url = format!(
|
||||||
|
"https://www.python.org/ftp/python/{}/python-{}-embed-amd64.zip",
|
||||||
|
super::BUNDLED_PY_VERSION,
|
||||||
|
super::BUNDLED_PY_VERSION
|
||||||
|
);
|
||||||
|
self.download_stream(app, &zip_url, &zip_path).await?;
|
||||||
|
|
||||||
|
// 解压到 runtime/python/
|
||||||
|
self.emit_progress(app, "extract", 42, 0, None, "正在解压便携 Python...").await;
|
||||||
|
let python_dir = runtime_dir.join("python");
|
||||||
|
if python_dir.exists() {
|
||||||
|
fs::remove_dir_all(&python_dir).ok();
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&python_dir).map_err(|e| e.to_string())?;
|
||||||
|
self.extract_zip(&zip_path, &python_dir)?;
|
||||||
|
fs::remove_file(&zip_path).ok();
|
||||||
|
|
||||||
|
// _pth 补丁:启用 site(否则无法识别 site-packages 与 pip)
|
||||||
|
self.emit_progress(app, "patch", 52, 0, None, "正在配置 Python 环境...").await;
|
||||||
|
self.patch_pth(&python_dir)?;
|
||||||
|
|
||||||
|
// 下载 get-pip.py 并引导 pip(只装 pip 本体,走官方源,包很小)
|
||||||
|
self.emit_progress(app, "pip", 54, 0, None, "正在引导 pip...").await;
|
||||||
|
let get_pip = runtime_dir.join("get-pip.py");
|
||||||
|
self.download_bytes(&"https://bootstrap.pypa.io/get-pip.py".to_string(), &get_pip)
|
||||||
|
.await?;
|
||||||
|
let pip_py = python_exe.clone();
|
||||||
|
let pip_py2 = pip_py.clone(); // 供 setuptools 步骤闭包使用(先于 move 克隆)
|
||||||
|
let get_pip2 = get_pip.clone();
|
||||||
|
self.run_blocking_step(
|
||||||
|
app,
|
||||||
|
"pip",
|
||||||
|
55,
|
||||||
|
60,
|
||||||
|
move |pid| {
|
||||||
|
let mut cmd = std::process::Command::new(&pip_py);
|
||||||
|
cmd.arg(&get_pip2).args(["--no-warn-script-location"]);
|
||||||
|
run_cmd_blocking(cmd, pid)
|
||||||
|
},
|
||||||
|
"正在安装 pip...",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
fs::remove_file(&get_pip).ok();
|
||||||
|
|
||||||
|
// 安装 setuptools(musicdl 的 setup.py 构建依赖 setuptools.build_meta,
|
||||||
|
// 必须先于 musicdl 就位,否则构建阶段报 BackendUnavailable)
|
||||||
|
self.run_blocking_step(
|
||||||
|
app,
|
||||||
|
"pip",
|
||||||
|
62,
|
||||||
|
66,
|
||||||
|
move |pid| {
|
||||||
|
let mut cmd = std::process::Command::new(&pip_py2);
|
||||||
|
cmd.args([
|
||||||
|
"-m", "pip", "install", "--no-warn-script-location",
|
||||||
|
"--timeout", "60",
|
||||||
|
"--index-url", PIP_INDEX_URL,
|
||||||
|
"setuptools",
|
||||||
|
]);
|
||||||
|
run_cmd_blocking(cmd, pid)
|
||||||
|
},
|
||||||
|
"正在安装 setuptools...",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
self.emit_progress(app, "check", 2, 0, None, "便携 Python 已就绪").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. 安装 musicdl(幂等:已安装则跳过) ----------
|
||||||
|
if !self.musicdl_ready(&python_exe).await {
|
||||||
|
let pip_py = python_exe.clone();
|
||||||
|
self.run_blocking_step(
|
||||||
|
app,
|
||||||
|
"musicdl",
|
||||||
|
68,
|
||||||
|
95,
|
||||||
|
move |pid| {
|
||||||
|
let mut cmd = std::process::Command::new(&pip_py);
|
||||||
|
cmd.args([
|
||||||
|
"-m", "pip", "install", "--no-warn-script-location",
|
||||||
|
"--timeout", "60",
|
||||||
|
"--index-url", PIP_INDEX_URL,
|
||||||
|
&format!("musicdl=={}", super::MUSICDL_VERSION),
|
||||||
|
]);
|
||||||
|
run_cmd_blocking(cmd, pid)
|
||||||
|
},
|
||||||
|
"正在安装 musicdl(下载依赖较多,可能需几分钟)...",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.emit_progress(app, "done", 100, 0, None, "环境就绪").await;
|
||||||
|
crate::logger::log_info("music", "便携 Python + musicdl 安装完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查便携 Python 能否导入 musicdl
|
||||||
|
async fn musicdl_ready(&self, python_exe: &PathBuf) -> bool {
|
||||||
|
let exe = python_exe.clone();
|
||||||
|
let (ok, _) = super::check_musicdl(&exe);
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把已装的 musicdl 对齐到本应用锁定的版本(`MUSICDL_VERSION`)。
|
||||||
|
///
|
||||||
|
/// 为什么必须有这个动作:`install_runtime_inner` 的闸门是「能否 import」而不是
|
||||||
|
/// 「版本是否一致」——仅升级应用(哪怕代码里的锁定版本提高了)**不会**触发 pip,
|
||||||
|
/// 已装环境会永远停在旧版本。这里显式执行 `pip install --upgrade musicdl==<pinned>`;
|
||||||
|
/// `force = true` 用 `--force-reinstall` 兜住「能 import 但依赖已损坏」的灰区
|
||||||
|
/// (`check_musicdl` 只验证 `import musicdl` 与 `__version__`,证明不了子模块可用)。
|
||||||
|
///
|
||||||
|
/// 只允许升到**锁定版本**,绝不升到 PyPI 最新:`bridge.py` 对 musicdl 的 monkey patch
|
||||||
|
/// 与版本强耦合(第三方解析链方法名、各源搜索字段映射、音质常量前缀),
|
||||||
|
/// 任意升版会静默破坏解析链。升锁定版本时必须同步核对那些补丁。
|
||||||
|
pub async fn update_musicdl(&self, app: &AppHandle, force: bool) -> Result<(), String> {
|
||||||
|
self.runtime_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let result = self.update_musicdl_inner(app, force).await;
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
if e != RUNTIME_CANCELLED {
|
||||||
|
let _ = app.emit(
|
||||||
|
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||||
|
MusicInstallProgress {
|
||||||
|
stage: "error".into(),
|
||||||
|
percent: 0,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: e.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_musicdl_inner(&self, app: &AppHandle, force: bool) -> Result<(), String> {
|
||||||
|
let python_exe = self.bundled_python_exe();
|
||||||
|
// 便携 Python 不可用(或跑不起来):直接走完整安装流程,
|
||||||
|
// 它会把 Python / pip / setuptools / musicdl 一次装齐
|
||||||
|
if !(python_exe.exists() && super::run_python_version(&python_exe).is_some()) {
|
||||||
|
return self.install_runtime_inner(app).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"-m".into(),
|
||||||
|
"pip".into(),
|
||||||
|
"install".into(),
|
||||||
|
"--no-warn-script-location".into(),
|
||||||
|
"--timeout".into(),
|
||||||
|
"60".into(),
|
||||||
|
"--index-url".into(),
|
||||||
|
PIP_INDEX_URL.into(),
|
||||||
|
];
|
||||||
|
args.push(if force {
|
||||||
|
"--force-reinstall".into()
|
||||||
|
} else {
|
||||||
|
"--upgrade".into()
|
||||||
|
});
|
||||||
|
args.push(format!("musicdl=={}", super::MUSICDL_VERSION));
|
||||||
|
let msg = if force {
|
||||||
|
"正在修复 musicdl(强制重装,依赖较多,可能需几分钟)...".to_string()
|
||||||
|
} else {
|
||||||
|
format!("正在更新 musicdl 到 {}...", super::MUSICDL_VERSION)
|
||||||
|
};
|
||||||
|
|
||||||
|
let exe = python_exe.clone();
|
||||||
|
self.run_blocking_step(
|
||||||
|
app,
|
||||||
|
"musicdl",
|
||||||
|
10,
|
||||||
|
95,
|
||||||
|
move |pid| {
|
||||||
|
let mut cmd = std::process::Command::new(&exe);
|
||||||
|
cmd.args(&args);
|
||||||
|
run_cmd_blocking(cmd, pid)
|
||||||
|
},
|
||||||
|
&msg,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.emit_progress(app, "done", 100, 0, None, "musicdl 已对齐到锁定版本")
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 阶段工具 ----------
|
||||||
|
async fn emit_progress(
|
||||||
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
stage: &str,
|
||||||
|
percent: u32,
|
||||||
|
downloaded_bytes: u64,
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
message: &str,
|
||||||
|
) {
|
||||||
|
let _ = app.emit(
|
||||||
|
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||||||
|
MusicInstallProgress {
|
||||||
|
stage: stage.into(),
|
||||||
|
percent,
|
||||||
|
downloaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
message: message.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式下载(带取消 + 进度事件,percent 0-40 区间),复用内核下载模式
|
||||||
|
async fn download_stream(
|
||||||
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
url: &str,
|
||||||
|
dest: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||||||
|
}
|
||||||
|
let total: Option<u64> = resp.content_length();
|
||||||
|
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
let mut last_percent: u32 = 0;
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
if self.is_cancelled() {
|
||||||
|
drop(file);
|
||||||
|
fs::remove_file(dest).ok();
|
||||||
|
return Err(RUNTIME_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
let chunk = chunk.map_err(|e| format!("下载中断: {}", e))?;
|
||||||
|
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
let percent = total
|
||||||
|
.filter(|t| *t > 0)
|
||||||
|
.map(|t| ((downloaded as f64 / t as f64) * 38.0) as u32)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(38);
|
||||||
|
if percent >= last_percent + 1 {
|
||||||
|
last_percent = percent;
|
||||||
|
self.emit_progress(
|
||||||
|
app,
|
||||||
|
"download",
|
||||||
|
percent,
|
||||||
|
downloaded,
|
||||||
|
total,
|
||||||
|
&format!("正在下载便携 Python ({:.1} MB)", downloaded as f64 / 1048576.0),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.flush().ok();
|
||||||
|
self.emit_progress(app, "download", 40, downloaded, total, "下载完成").await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 小文件整体下载(get-pip.py),无进度
|
||||||
|
async fn download_bytes(&self, url: &str, dest: &Path) -> Result<(), String> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await.map_err(|e| format!("读取响应失败: {}", e))?;
|
||||||
|
fs::write(dest, &bytes).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解压 zip(复用内核解压逻辑)
|
||||||
|
fn extract_zip(&self, zip_path: &Path, dest: &Path) -> Result<(), String> {
|
||||||
|
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?;
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut entry = archive
|
||||||
|
.by_index(i)
|
||||||
|
.map_err(|e| format!("读取条目失败: {}", e))?;
|
||||||
|
let outpath = match entry.enclosed_name() {
|
||||||
|
Some(p) => dest.join(p),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if entry.is_dir() {
|
||||||
|
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = outpath.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = entry.read(&mut buf).map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改 pythonXY._pth:启用 `import site`(embeddable 默认注释掉,
|
||||||
|
/// 不启用则无法识别 site-packages / pip 安装的包)
|
||||||
|
fn patch_pth(&self, python_dir: &Path) -> Result<(), String> {
|
||||||
|
let entries = fs::read_dir(python_dir).map_err(|e| e.to_string())?;
|
||||||
|
let pth = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.find(|p| {
|
||||||
|
p.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("_pth"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| "解压目录中未找到 ._pth 文件".to_string())?;
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&pth).map_err(|e| e.to_string())?;
|
||||||
|
let mut patched = content.replace("#import site", "import site");
|
||||||
|
if !patched.contains("import site") {
|
||||||
|
patched.push_str("import site\n");
|
||||||
|
}
|
||||||
|
// 显式把 site-packages 加入 sys.path(pip 默认安装位置)
|
||||||
|
if !patched.contains("Lib\\site-packages") && !patched.contains("Lib/site-packages") {
|
||||||
|
patched.push_str("Lib\\site-packages\n");
|
||||||
|
}
|
||||||
|
fs::write(&pth, patched).map_err(|e| format!("写入 _pth 失败: {}", e))?;
|
||||||
|
crate::logger::log_info("music", &format!("已补丁 _pth: {}", pth.display()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行一个阻塞子进程步骤(get-pip / setuptools / musicdl),带进度推送、取消检查与超时看门狗。
|
||||||
|
/// start_percent / end_percent:本步骤的进度区间(完成前推进到 end_percent)。
|
||||||
|
/// run 闭包接收「子进程 pid 记录器」,供取消时 taskkill。
|
||||||
|
async fn run_blocking_step<F>(
|
||||||
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
stage: &str,
|
||||||
|
start_percent: u32,
|
||||||
|
end_percent: u32,
|
||||||
|
run: F,
|
||||||
|
msg: &str,
|
||||||
|
) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: FnOnce(Arc<std::sync::atomic::AtomicU64>) -> Result<(), String> + Send + 'static,
|
||||||
|
{
|
||||||
|
if self.is_cancelled() {
|
||||||
|
return Err(RUNTIME_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
let pid_ref = self.install_pid.clone();
|
||||||
|
self.emit_progress(app, stage, start_percent, 0, None, msg).await;
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || run(pid_ref.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("任务执行失败: {}", e))?;
|
||||||
|
self.install_pid.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
if self.is_cancelled() {
|
||||||
|
return Err(RUNTIME_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
result?;
|
||||||
|
self.emit_progress(app, stage, end_percent, 0, None, "完成").await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步运行子进程,带超时看门狗(超时 taskkill),并记录 pid 供取消。
|
||||||
|
/// 输出(stdout+stderr 尾部)写入日志;失败返回错误信息。
|
||||||
|
fn run_cmd_blocking(
|
||||||
|
mut cmd: std::process::Command,
|
||||||
|
pid_ref: Arc<std::sync::atomic::AtomicU64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
cmd.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
crate::process_manager::setup_creation_flags(&mut cmd);
|
||||||
|
|
||||||
|
let child = cmd.spawn().map_err(|e| format!("启动子进程失败: {}", e))?;
|
||||||
|
pid_ref.store(child.id() as u64, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
|
// 超时看门狗:30 分钟后仍未结束则强杀(慢网络下 pip 装 musicdl 依赖可能超过 10 分钟)
|
||||||
|
let pid = child.id();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let watcher = std::thread::spawn(move || {
|
||||||
|
if rx.recv_timeout(std::time::Duration::from_secs(1800)).is_err() {
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = child.wait_with_output();
|
||||||
|
let _ = tx.send(());
|
||||||
|
let _ = watcher.join();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// 截取尾部 800 字符,便于定位 pip 报错
|
||||||
|
let tail: String = text.chars().rev().take(800).collect::<String>().chars().rev().collect();
|
||||||
|
Err(format!("子进程退出码 {}: {}", out.status.code().unwrap_or(-1), tail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("读取子进程输出失败: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! 音乐模块的凭据键约定与便捷读取。
|
||||||
|
//!
|
||||||
|
//! 底层读写原语已抽到 crate 级 [`crate::secrets`](同一套 `Thing` 服务名与迁移
|
||||||
|
//! 策略,翻译模块等其他使用者共享)。本文件只保留**音乐自己的键名**与
|
||||||
|
//! 「前端可达范围」这道闸门,读写一律委托给公共模块,避免出现第二套实现。
|
||||||
|
//!
|
||||||
|
//! 约定:
|
||||||
|
//! - 一律使用 `Thing` 作为凭据服务名,`key` 作为用户名(Entry 的 account)。
|
||||||
|
//! 服务名是历史值,改动会导致已有凭据读不到。
|
||||||
|
//! - 明文只允许存在于内存与系统凭据库,禁止回写 `settings.json` / localStorage。
|
||||||
|
//! - 迁移采用「先写凭据库成功、再清明文」的顺序;**写失败时保留明文**,
|
||||||
|
//! 宁可牺牲一致性也不能把用户已登录的会话弄丢。
|
||||||
|
//! - 非 Windows 平台没有凭据管理器:读取返回 None、写入报错,
|
||||||
|
//! 调用方据此退化为「明文存 settings」(功能优先)。
|
||||||
|
|
||||||
|
pub use crate::secrets::{secret_delete, secret_read, secret_write, try_store};
|
||||||
|
|
||||||
|
/// WebDAV 凭据的 key(**历史值,不可更改**)。
|
||||||
|
pub const KEY_WEBDAV: &str = "webdav-credentials";
|
||||||
|
/// QQ 音乐 Cookie 的 key(前端 `musicStore` 使用)。
|
||||||
|
pub const KEY_QQ_COOKIE: &str = "music-qq-cookie";
|
||||||
|
|
||||||
|
/// 允许**前端**通过 `music_secret_*` 命令读写的凭据键白名单。
|
||||||
|
///
|
||||||
|
/// 用白名单而不是前缀匹配:前端不该有能力枚举/试探凭据库,
|
||||||
|
/// `webdav-credentials` 与飞牛 token 因此都在前端的可达范围之外。
|
||||||
|
/// 新增键必须在此显式登记(改 Rust 代码),这是一道有意的闸门。
|
||||||
|
pub const FRONTEND_KEYS: [&str; 1] = [KEY_QQ_COOKIE];
|
||||||
|
|
||||||
|
/// 飞牛连接 token 的 key 前缀(**只由后端使用**,不暴露给前端)。
|
||||||
|
const TOKEN_KEY_PREFIX: &str = "music-feiniu-token-";
|
||||||
|
|
||||||
|
/// 某条飞牛连接的登录 token 的 key。
|
||||||
|
pub fn feiniu_token_key(connection_id: &str) -> String {
|
||||||
|
format!("{TOKEN_KEY_PREFIX}{connection_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端是否允许访问该凭据键。
|
||||||
|
pub fn frontend_key_allowed(key: &str) -> bool {
|
||||||
|
FRONTEND_KEYS.contains(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取飞牛连接 token;未配置或读取失败 → 空串(等价于未登录)。
|
||||||
|
pub fn read_feiniu_token(connection_id: &str) -> String {
|
||||||
|
if connection_id.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
crate::secrets::read_or_empty(&feiniu_token_key(connection_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该连接是否已有可用 token(供前端展示「已登录」)。
|
||||||
|
pub fn has_feiniu_token(connection_id: &str) -> bool {
|
||||||
|
!read_feiniu_token(connection_id).is_empty()
|
||||||
|
}
|
||||||
+196
-6
@@ -15,9 +15,12 @@ use tauri::{AppHandle, Emitter};
|
|||||||
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
static DRAG_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
/// 任务栏覆盖监视线程停止标志
|
/// 任务栏覆盖监视线程停止标志
|
||||||
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
|
/// 游戏全屏监视线程停止标志
|
||||||
|
static GAME_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||||||
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
||||||
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
static GAME_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
|
||||||
fn drag_stop() -> &'static Arc<AtomicBool> {
|
fn drag_stop() -> &'static Arc<AtomicBool> {
|
||||||
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
@@ -27,6 +30,10 @@ fn topmost_stop() -> &'static Arc<AtomicBool> {
|
|||||||
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn game_stop() -> &'static Arc<AtomicBool> {
|
||||||
|
GAME_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||||||
|
}
|
||||||
|
|
||||||
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
|
/// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出)
|
||||||
fn stop_drag_thread() {
|
fn stop_drag_thread() {
|
||||||
drag_stop().store(true, Ordering::SeqCst);
|
drag_stop().store(true, Ordering::SeqCst);
|
||||||
@@ -51,18 +58,35 @@ fn stop_topmost_thread() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 停止游戏全屏监视线程并等待其退出
|
||||||
|
fn stop_game_thread() {
|
||||||
|
game_stop().store(true, Ordering::SeqCst);
|
||||||
|
if let Some(h) = GAME_HANDLE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.take()
|
||||||
|
{
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod win_api {
|
mod win_api {
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use windows_sys::Win32::Foundation::{POINT, RECT};
|
use windows_sys::Win32::Foundation::{POINT, RECT};
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||||
|
};
|
||||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON};
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW,
|
GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowLongW,
|
||||||
GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
GetWindowRect, GetWindowThreadProcessId, SendMessageW, SetWindowLongPtrW, SetWindowPos,
|
||||||
GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
|
GWL_EXSTYLE, GWL_STYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE,
|
||||||
SWP_NOSIZE, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
|
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE,
|
||||||
WS_EX_TRANSPARENT,
|
WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT,
|
||||||
};
|
};
|
||||||
|
/// 供模块外全屏判定使用的窗口样式常量(pub re-export)
|
||||||
|
pub use windows_sys::Win32::UI::WindowsAndMessaging::WS_CAPTION;
|
||||||
|
|
||||||
/// windows-sys 的 HWND 类型别名(isize)
|
/// windows-sys 的 HWND 类型别名(isize)
|
||||||
pub type Hwnd = isize;
|
pub type Hwnd = isize;
|
||||||
@@ -165,6 +189,10 @@ mod win_api {
|
|||||||
} else {
|
} else {
|
||||||
HWND_NOTOPMOST
|
HWND_NOTOPMOST
|
||||||
};
|
};
|
||||||
|
// 注意:不传 SWP_SHOWWINDOW,仅调整 Z 序,绝不改变窗口可见性。
|
||||||
|
// 否则当 OSD 被 .hide() 隐藏后,任务栏覆盖监视线程在系统 UI 前景切换时
|
||||||
|
// (点击任务栏/托盘关闭主界面、打开托盘菜单)会重新显示已隐藏的 OSD,
|
||||||
|
// 表现为"托盘关闭 OSD 无效 / 关闭主界面后 OSD 又出现"。
|
||||||
SetWindowPos(
|
SetWindowPos(
|
||||||
hwnd,
|
hwnd,
|
||||||
insert_after,
|
insert_after,
|
||||||
@@ -172,7 +200,7 @@ mod win_api {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
|
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +218,24 @@ mod win_api {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 原子设置窗口位置与尺寸(物理像素)
|
||||||
|
///
|
||||||
|
/// 一次 SetWindowPos 调用同时更新 x/y/w/h,避免 setSize + setPosition
|
||||||
|
/// 两次调用之间出现"宽度已变、位置未动"的中间帧(视觉闪烁)。
|
||||||
|
pub fn set_bounds(hwnd: Hwnd, x: i32, y: i32, w: i32, h: i32) {
|
||||||
|
unsafe {
|
||||||
|
SetWindowPos(
|
||||||
|
hwnd,
|
||||||
|
0, // 不改 Z 序
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
SWP_NOACTIVATE | SWP_NOZORDER,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等)
|
/// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等)
|
||||||
pub fn is_system_ui_class(class_name: &str) -> bool {
|
pub fn is_system_ui_class(class_name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -203,6 +249,41 @@ mod win_api {
|
|||||||
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
| "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取窗口样式(GWL_STYLE)
|
||||||
|
pub fn get_window_style(hwnd: Hwnd) -> isize {
|
||||||
|
unsafe { GetWindowLongW(hwnd, GWL_STYLE) as isize }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断窗口是否属于本进程(Thing 自身窗口不参与全屏判定)
|
||||||
|
pub fn is_own_process(hwnd: Hwnd) -> bool {
|
||||||
|
let mut pid: u32 = 0;
|
||||||
|
unsafe {
|
||||||
|
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||||
|
}
|
||||||
|
pid != 0 && pid == std::process::id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取窗口所在显示器(最近匹配)的矩形(物理像素)
|
||||||
|
pub fn get_monitor_rect(hwnd: Hwnd) -> Option<RECT> {
|
||||||
|
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
||||||
|
if monitor == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut info = MONITORINFO {
|
||||||
|
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||||
|
rcMonitor: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||||
|
rcWork: RECT { left: 0, top: 0, right: 0, bottom: 0 },
|
||||||
|
dwFlags: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if GetMonitorInfoW(monitor, &mut info) != 0 {
|
||||||
|
Some(info.rcMonitor)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow)
|
||||||
@@ -347,11 +428,98 @@ pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断窗口是否为全屏应用(无边框/独占全屏游戏)
|
||||||
|
///
|
||||||
|
/// 判定条件(全部满足):
|
||||||
|
/// 1. 无 WS_CAPTION 样式 —— 排除普通应用的"最大化"(即使系统任务栏设为自动隐藏,
|
||||||
|
/// 最大化窗口覆盖率也接近 100%,但它们带标题栏,靠样式即可区分)
|
||||||
|
/// 2. 非本进程窗口(Thing 主窗口/悬浮窗自身)
|
||||||
|
/// 3. 窗口矩形与所在显示器矩形的交集覆盖率 ≥ 95%(兼容缩放/1px 误差)
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_fullscreen_game_window(hwnd: isize) -> bool {
|
||||||
|
if win_api::get_window_style(hwnd) & (win_api::WS_CAPTION as isize) != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if win_api::is_own_process(hwnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (Some(win_rect), Some(mon_rect)) = (
|
||||||
|
win_api::get_window_rect(hwnd),
|
||||||
|
win_api::get_monitor_rect(hwnd),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let iw = (win_rect.right.min(mon_rect.right) - win_rect.left.max(mon_rect.left)).max(0) as i64;
|
||||||
|
let ih = (win_rect.bottom.min(mon_rect.bottom) - win_rect.top.max(mon_rect.top)).max(0) as i64;
|
||||||
|
let mw = (mon_rect.right - mon_rect.left).max(0) as i64;
|
||||||
|
let mh = (mon_rect.bottom - mon_rect.top).max(0) as i64;
|
||||||
|
let mon_area = (mw * mh).max(1);
|
||||||
|
iw * ih * 100 >= mon_area * 95
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动游戏全屏监视
|
||||||
|
///
|
||||||
|
/// 轮询检测前景窗口是否为全屏应用(无边框/独占全屏游戏),
|
||||||
|
/// 状态变化时发出 `osd-game-active` / `osd-game-inactive` 事件。
|
||||||
|
/// 前端据此隐藏/恢复 OSD:透明置顶 WebView 悬浮窗会占用 DWM 合成路径,
|
||||||
|
/// 禁用游戏的独立翻转(MPO),是游戏中掉帧的根源;隐藏悬浮窗即可排除影响。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn osd_start_game_watch(app: AppHandle) -> Result<(), String> {
|
||||||
|
// 停止旧线程,等待其退出后再启动新线程
|
||||||
|
stop_game_thread();
|
||||||
|
let stop_flag = game_stop().clone();
|
||||||
|
stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let mut fullscreen_active = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if stop_flag.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let fg = win_api::get_foreground_window();
|
||||||
|
let fullscreen = fg != 0
|
||||||
|
&& win_api::get_class_name(fg)
|
||||||
|
.map_or(false, |c| !win_api::is_system_ui_class(&c))
|
||||||
|
&& is_fullscreen_game_window(fg);
|
||||||
|
|
||||||
|
if fullscreen != fullscreen_active {
|
||||||
|
fullscreen_active = fullscreen;
|
||||||
|
let event = if fullscreen {
|
||||||
|
crate::constants::events::OSD_GAME_ACTIVE
|
||||||
|
} else {
|
||||||
|
crate::constants::events::OSD_GAME_INACTIVE
|
||||||
|
};
|
||||||
|
let _ = app_handle.emit(event, ());
|
||||||
|
crate::logger::log_info(
|
||||||
|
"osd",
|
||||||
|
&format!("全屏应用前台: {}", if fullscreen { "是 → 隐藏 OSD" } else { "否 → 恢复 OSD" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(1000));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Ok(mut guard) = GAME_HANDLE.lock() {
|
||||||
|
*guard = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 停止所有 OSD 监视线程
|
/// 停止所有 OSD 监视线程
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn osd_stop_watch() {
|
pub fn osd_stop_watch() {
|
||||||
stop_drag_thread();
|
stop_drag_thread();
|
||||||
stop_topmost_thread();
|
stop_topmost_thread();
|
||||||
|
stop_game_thread();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠)
|
||||||
@@ -378,6 +546,28 @@ pub fn osd_set_topmost(label: String, topmost: bool, app: AppHandle) -> Result<(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 原子设置窗口位置与尺寸(物理像素)
|
||||||
|
///
|
||||||
|
/// 一次调用同时更新位置和尺寸,避免 setSize + setPosition 两次 IPC 之间的
|
||||||
|
/// 中间帧(宽度已变、位置未动 → 视觉闪烁)。前端传入物理像素坐标。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn osd_set_bounds(
|
||||||
|
label: String,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let hwnd = win_api::get_hwnd(&label, &app)
|
||||||
|
.ok_or_else(|| format!("窗口 {} 不存在", label))?;
|
||||||
|
win_api::set_bounds(hwnd, x, y, w, h);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 启动原生拖动(右键长按触发)
|
/// 启动原生拖动(右键长按触发)
|
||||||
///
|
///
|
||||||
/// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用
|
/// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ use tauri::{AppHandle, Emitter, Manager};
|
|||||||
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
// CREATE_NO_WINDOW = 0x08000000,阻止子进程创建新的控制台窗口
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
// CREATE_NEW_CONSOLE = 0x00000010,强制为控制台类子进程新开一个可见控制台窗口。
|
||||||
|
// 从 GUI 宿主(无控制台)直接 spawn cmd/powershell 等控制台程序时若不设置,
|
||||||
|
// 子进程会挂到隐藏控制台/不显示窗口,表现为"点击没反应"。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub const CREATE_NEW_CONSOLE: u32 = 0x00000010;
|
||||||
|
|
||||||
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -122,8 +127,9 @@ pub fn setup_creation_flags(_cmd: &mut Command) {
|
|||||||
|
|
||||||
/// 将已启动的子进程加入 Job Object(异常退出时自动清理)
|
/// 将已启动的子进程加入 Job Object(异常退出时自动清理)
|
||||||
/// 在 Windows 上调用,非 Windows 平台为空操作
|
/// 在 Windows 上调用,非 Windows 平台为空操作
|
||||||
|
/// pub(crate):音乐模块的桥接进程(自管 stdio,不走 ProcessManager)也需加入 Job
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn assign_to_job(child: &Child) {
|
pub(crate) fn assign_to_job(child: &Child) {
|
||||||
use std::os::windows::io::AsRawHandle;
|
use std::os::windows::io::AsRawHandle;
|
||||||
if let Some(job) = get_job_handle() {
|
if let Some(job) = get_job_handle() {
|
||||||
let child_handle = child.as_raw_handle() as winapi::HANDLE;
|
let child_handle = child.as_raw_handle() as winapi::HANDLE;
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
//! 快速面板:基于 7-Zip(binaries/7z.exe)的批量文件操作。
|
||||||
|
//!
|
||||||
|
//! - 批量解压:逐文件调用 7z 控制台,支持一次传入统一密码(`-p`),
|
||||||
|
//! 每完成一个文件通过事件推送进度,前端可实时展示。
|
||||||
|
//! - 批量重命名:`regex` 匹配文件名生成预览,确认后执行 `fs::rename`。
|
||||||
|
//! - 目录列表:供前端展示当前目录的压缩包 / 文件列表。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
|
use crate::constants::events::QUICKPANEL_EXTRACT_PROGRESS;
|
||||||
|
|
||||||
|
/// 7z 可执行文件名(与 7z.dll 同目录,位于 resources/binaries)。
|
||||||
|
const _7Z_EXE: &str = "binaries/7z.exe";
|
||||||
|
|
||||||
|
/// 支持的压缩包扩展名(解压面板中列出)。
|
||||||
|
const ARCHIVE_EXTS: &[&str] = &[
|
||||||
|
".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".xz", ".txz", ".zst",
|
||||||
|
".tzst", ".cab", ".lzma",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 定位 7z 可执行文件(resources/binaries/7z.exe)。
|
||||||
|
fn locate_7z(app: &AppHandle) -> Option<PathBuf> {
|
||||||
|
let path = app
|
||||||
|
.path()
|
||||||
|
.resolve(_7Z_EXE, tauri::path::BaseDirectory::Resource)
|
||||||
|
.ok()?;
|
||||||
|
if path.exists() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArchiveInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExtractResult {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub ok: bool,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExtractProgress {
|
||||||
|
pub done: usize,
|
||||||
|
pub total: usize,
|
||||||
|
pub current: String,
|
||||||
|
pub ok: bool,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RenamePreview {
|
||||||
|
pub path: String,
|
||||||
|
pub old_name: String,
|
||||||
|
pub new_name: String,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RenameItem {
|
||||||
|
pub path: String,
|
||||||
|
pub old_name: String,
|
||||||
|
pub new_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RenameResult {
|
||||||
|
pub old_name: String,
|
||||||
|
pub new_name: String,
|
||||||
|
pub ok: bool,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_archive(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
ARCHIVE_EXTS.iter().any(|ext| lower.ends_with(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出目录下的压缩包文件(供批量解压面板使用)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_list_archives(dir: String) -> Result<Vec<ArchiveInfo>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let d = Path::new(&dir);
|
||||||
|
if !d.is_dir() {
|
||||||
|
return Err(format!("目录不存在: {dir}"));
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
|
||||||
|
let Ok(entry) = entry else { continue };
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if is_archive(&name) {
|
||||||
|
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
out.push(ArchiveInfo {
|
||||||
|
name,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("读取压缩包失败: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_list_dir(dir: String) -> Result<Vec<FileEntry>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let d = Path::new(&dir);
|
||||||
|
if !d.is_dir() {
|
||||||
|
return Err(format!("目录不存在: {dir}"));
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
|
||||||
|
let Ok(entry) = entry else { continue };
|
||||||
|
let path = entry.path();
|
||||||
|
let is_dir = path.is_dir();
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let size = if is_dir {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
path.metadata().map(|m| m.len()).unwrap_or(0)
|
||||||
|
};
|
||||||
|
out.push(FileEntry {
|
||||||
|
name,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
is_dir,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| {
|
||||||
|
b.is_dir
|
||||||
|
.cmp(&a.is_dir)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("读取目录失败: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_one(
|
||||||
|
exe: &Path,
|
||||||
|
archive: &str,
|
||||||
|
dest: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
into_subfolder: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let archive_path = Path::new(archive);
|
||||||
|
let mut cmd = Command::new(exe);
|
||||||
|
cmd.arg("x").arg(archive_path);
|
||||||
|
if into_subfolder {
|
||||||
|
// 解压到「压缩包同名子文件夹」,避免文件散落在当前目录。
|
||||||
|
let sub = archive_path
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "extracted".into());
|
||||||
|
let sub_dir = Path::new(dest).join(&sub);
|
||||||
|
std::fs::create_dir_all(&sub_dir).map_err(|e| format!("创建目录 {sub} 失败: {e}"))?;
|
||||||
|
cmd.arg(format!("-o{}", sub_dir.display()));
|
||||||
|
} else {
|
||||||
|
cmd.arg(format!("-o{}", dest));
|
||||||
|
}
|
||||||
|
cmd.arg("-y"); // 全自动覆盖确认
|
||||||
|
if let Some(p) = password.filter(|p| !p.is_empty()) {
|
||||||
|
cmd.arg(format!("-p{p}"));
|
||||||
|
}
|
||||||
|
// 静默普通输出,仅错误进 stderr,逐文件粒度足够时无需 -bsp1 进度。
|
||||||
|
cmd.arg("-bso0").arg("-bse1").arg("-bsp0");
|
||||||
|
// 以 7z.exe 所在目录为工作目录,确保同目录的 7z.dll 可被加载。
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
|
let output = cmd.output().map_err(|e| format!("启动 7-Zip 失败: {e}"))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let code = output.status.code().unwrap_or(-1);
|
||||||
|
Err(if msg.is_empty() {
|
||||||
|
format!("退出码 {code}")
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录,
|
||||||
|
/// `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。
|
||||||
|
/// 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_batch_extract(
|
||||||
|
app: AppHandle,
|
||||||
|
files: Vec<String>,
|
||||||
|
dest_dir: String,
|
||||||
|
password: Option<String>,
|
||||||
|
into_subfolder: bool,
|
||||||
|
) -> Result<Vec<ExtractResult>, String> {
|
||||||
|
let exe = locate_7z(&app).ok_or("未找到 7-Zip 组件(binaries/7z.exe),请重新安装")?;
|
||||||
|
let total = files.len();
|
||||||
|
let results = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut results = Vec::with_capacity(total);
|
||||||
|
for (idx, file) in files.iter().enumerate() {
|
||||||
|
let err = extract_one(&exe, file, &dest_dir, password.as_deref(), into_subfolder);
|
||||||
|
let name = Path::new(file)
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| file.clone());
|
||||||
|
let (ok, error) = match err {
|
||||||
|
Ok(()) => (true, String::new()),
|
||||||
|
Err(e) => (false, e),
|
||||||
|
};
|
||||||
|
results.push(ExtractResult {
|
||||||
|
name,
|
||||||
|
path: file.clone(),
|
||||||
|
ok,
|
||||||
|
error: error.clone(),
|
||||||
|
});
|
||||||
|
let _ = app.emit(
|
||||||
|
QUICKPANEL_EXTRACT_PROGRESS,
|
||||||
|
ExtractProgress {
|
||||||
|
done: idx + 1,
|
||||||
|
total,
|
||||||
|
current: results[idx].name.clone(),
|
||||||
|
ok,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("解压任务异常终止: {e}"))?;
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 正则批量重命名预览:对每个文件名应用 `pattern → replacement`,
|
||||||
|
/// 仅返回有匹配的文件,`newName` 为替换结果。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_preview_rename(
|
||||||
|
files: Vec<String>,
|
||||||
|
pattern: String,
|
||||||
|
replacement: String,
|
||||||
|
) -> Result<Vec<RenamePreview>, String> {
|
||||||
|
let re = regex::Regex::new(&pattern).map_err(|e| format!("正则表达式无效: {e}"))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for file in files {
|
||||||
|
let name = Path::new(&file)
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !re.is_match(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_name = re.replace_all(&name, replacement.as_str()).to_string();
|
||||||
|
let error = if new_name.is_empty() || new_name == name {
|
||||||
|
"名称未变化".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
out.push(RenamePreview {
|
||||||
|
path: file,
|
||||||
|
old_name: name,
|
||||||
|
new_name,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行重命名。同一目录下若目标已存在则跳过该项。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_apply_rename(items: Vec<RenameItem>) -> Result<Vec<RenameResult>, String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for item in items {
|
||||||
|
let result = (|| -> Result<(), String> {
|
||||||
|
if item.new_name.is_empty() {
|
||||||
|
return Err("新文件名为空".into());
|
||||||
|
}
|
||||||
|
if item.new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||||
|
return Err("文件名包含非法字符".into());
|
||||||
|
}
|
||||||
|
let old = Path::new(&item.path);
|
||||||
|
let parent = old.parent().unwrap_or(Path::new("."));
|
||||||
|
let new_path = parent.join(&item.new_name);
|
||||||
|
if new_path.exists() {
|
||||||
|
return Err("目标已存在".into());
|
||||||
|
}
|
||||||
|
std::fs::rename(old, &new_path).map_err(|e| format!("重命名失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
out.push(RenameResult {
|
||||||
|
old_name: item.old_name,
|
||||||
|
new_name: item.new_name,
|
||||||
|
ok: result.is_ok(),
|
||||||
|
error: result.err().unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
//! Tauri 命令:快速面板模块
|
//! Tauri 命令:快速面板模块
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use super::popup::{self, QuickPanelSettings};
|
use super::popup::{self, QuickPanelSettings};
|
||||||
use super::{file_index, app_scanner, icon_extractor};
|
use super::{file_index, app_scanner, icon_extractor};
|
||||||
|
|
||||||
|
/// 批量删除单个条目的结果。
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteResult {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub ok: bool,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 读取快速面板设置(快捷键等)
|
/// 读取快速面板设置(快捷键等)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -12,14 +22,17 @@ pub async fn quickpanel_get_settings(app: AppHandle) -> Result<QuickPanelSetting
|
|||||||
Ok(popup::load_settings(&app))
|
Ok(popup::load_settings(&app))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口
|
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口,
|
||||||
|
/// 索引目录变化时闲时自动重建索引。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn quickpanel_save_settings(
|
pub async fn quickpanel_save_settings(
|
||||||
settings: QuickPanelSettings,
|
settings: QuickPanelSettings,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let prev_shortcut = popup::load_settings(&app).shortcut;
|
let prev = popup::load_settings(&app);
|
||||||
|
let prev_shortcut = prev.shortcut.clone();
|
||||||
|
let dirs_changed = prev.index_dirs != settings.index_dirs;
|
||||||
popup::save_settings(&app, &settings)?;
|
popup::save_settings(&app, &settings)?;
|
||||||
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
|
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
|
||||||
if settings.shortcut != prev_shortcut {
|
if settings.shortcut != prev_shortcut {
|
||||||
@@ -31,6 +44,10 @@ pub async fn quickpanel_save_settings(
|
|||||||
popup::ensure_window(&app);
|
popup::ensure_window(&app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 索引目录变更:闲时自动重建(新增/移除路径后无需手动点"构建索引")
|
||||||
|
if dirs_changed {
|
||||||
|
schedule_auto_build(app, true);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +93,26 @@ pub async fn quickpanel_show_window(app: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 显示主窗口并强制置为前台。
|
||||||
|
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台;
|
||||||
|
/// 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_focus_main_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
use crate::constants::windows::MAIN;
|
||||||
|
if let Some(window) = app.get_webview_window(MAIN) {
|
||||||
|
window.show().map_err(|e| e.to_string())?;
|
||||||
|
window.unminimize().map_err(|e| e.to_string())?;
|
||||||
|
match window.hwnd() {
|
||||||
|
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
|
||||||
|
Err(_) => {
|
||||||
|
window.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStation,CREATE_NO_WINDOW 避免黑窗)
|
/// 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStation,CREATE_NO_WINDOW 避免黑窗)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -95,25 +132,100 @@ pub fn quickpanel_lock_screen() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化文件索引数据库(应用启动时调用)
|
/// 闲时自动建立索引的启动延迟(秒):避开应用启动/模块加载的 IO 高峰
|
||||||
|
const AUTO_BUILD_START_DELAY: u64 = 6;
|
||||||
|
/// 判定"系统空闲"的阈值(毫秒):用户停止输入超过该时长才执行构建
|
||||||
|
const AUTO_BUILD_IDLE_MS: u64 = 3000;
|
||||||
|
/// 等待系统空闲的最长轮询次数(每次间隔 2s,约 60s 上限,超时后不再等待直接构建)
|
||||||
|
const AUTO_BUILD_MAX_WAIT_ITERS: u32 = 30;
|
||||||
|
|
||||||
|
/// 解析要索引的目录:设置为空时用默认(桌面/文档/下载)
|
||||||
|
fn resolve_index_dirs(app: &AppHandle) -> Vec<String> {
|
||||||
|
let settings = popup::load_settings(app);
|
||||||
|
if settings.index_dirs.is_empty() {
|
||||||
|
popup::QuickPanelSettings::default().index_dirs
|
||||||
|
} else {
|
||||||
|
settings.index_dirs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 闲时自动建立/重建文件索引。
|
||||||
|
/// - `force=false`:仅首次(从未构建过)自动建立
|
||||||
|
/// - `force=true`:忽略是否已构建,直接重建(索引目录变更后调用)
|
||||||
|
///
|
||||||
|
/// 流程:先延迟避开启动 IO 高峰,再轮询等待系统空闲(用户停止输入),
|
||||||
|
/// 空闲后才开始构建,避免与应用运行/用户操作抢 IO 导致卡顿。
|
||||||
|
/// 构建完成后向前端广播 `quickpanel-index-updated` 事件(负载为条目数),
|
||||||
|
/// 供设置页刷新统计、弹窗启用文件搜索。
|
||||||
|
fn schedule_auto_build(app: AppHandle, force: bool) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// 1. 启动延迟:避开应用启动、模块加载等 IO 高峰
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(AUTO_BUILD_START_DELAY)).await;
|
||||||
|
// 2. 轮询等待系统空闲(判定阈值见 AUTO_BUILD_IDLE_MS)
|
||||||
|
for _ in 0..AUTO_BUILD_MAX_WAIT_ITERS {
|
||||||
|
if crate::win32_util::get_idle_time_ms() >= AUTO_BUILD_IDLE_MS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
// 3. 非强制模式:已构建过则跳过(幂等,避免每次启动重建)
|
||||||
|
if !force {
|
||||||
|
file_index::ensure_initialized(&app);
|
||||||
|
let stats = file_index::stats();
|
||||||
|
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. 构建(build_index 内部有并发保护,重复调度/手动构建并发时幂等跳过)
|
||||||
|
let dirs = resolve_index_dirs(&app);
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs)).await {
|
||||||
|
Ok(Ok(count)) => {
|
||||||
|
crate::logger::log_info(
|
||||||
|
"quickpanel",
|
||||||
|
&format!("闲时自动索引完成,共 {} 条", count),
|
||||||
|
);
|
||||||
|
let _ = app.emit(crate::constants::events::QUICKPANEL_INDEX_UPDATED, count);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
crate::logger::log_error("quickpanel", "闲时自动索引失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化文件索引数据库(应用启动时调用)。
|
||||||
|
/// 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
|
||||||
|
/// 无需重建即可继续自动同步文件变更。
|
||||||
|
/// 若从未构建过(首次运行),闲时自动建立索引,无需用户手动点"构建索引"。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
|
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || file_index::init(&app))
|
let app_for_build = app.clone();
|
||||||
.await
|
let need_auto_build = tauri::async_runtime::spawn_blocking(move || {
|
||||||
.map_err(|e| format!("索引初始化任务失败: {}", e))
|
file_index::init(&app_for_build);
|
||||||
|
let stats = file_index::stats();
|
||||||
|
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
|
||||||
|
file_index::start_watcher(&stats.last_built_dirs);
|
||||||
|
}
|
||||||
|
// 从未构建过索引 → 需要闲时自动建立
|
||||||
|
stats.last_built_at == 0 || stats.last_built_dirs.is_empty()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("索引初始化任务失败: {}", e))?;
|
||||||
|
|
||||||
|
if need_auto_build {
|
||||||
|
schedule_auto_build(app, false);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
|
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
|
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
|
||||||
let settings = popup::load_settings(&app);
|
let dirs = resolve_index_dirs(&app);
|
||||||
let dirs = if settings.index_dirs.is_empty() {
|
// 懒加载:首次构建时自动初始化 DB 连接
|
||||||
popup::QuickPanelSettings::default().index_dirs
|
file_index::ensure_initialized(&app);
|
||||||
} else {
|
|
||||||
settings.index_dirs
|
|
||||||
};
|
|
||||||
// 阻塞操作放到 spawn_blocking
|
// 阻塞操作放到 spawn_blocking
|
||||||
tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs))
|
tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs))
|
||||||
.await
|
.await
|
||||||
@@ -126,7 +238,10 @@ pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String>
|
|||||||
pub async fn quickpanel_search_files(
|
pub async fn quickpanel_search_files(
|
||||||
query: String,
|
query: String,
|
||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
|
app: AppHandle,
|
||||||
) -> Result<Vec<file_index::FileRecord>, String> {
|
) -> Result<Vec<file_index::FileRecord>, String> {
|
||||||
|
// 懒加载:首次搜索时自动初始化 DB 连接
|
||||||
|
file_index::ensure_initialized(&app);
|
||||||
tauri::async_runtime::spawn_blocking(move || file_index::search(&query, limit.unwrap_or(50)))
|
tauri::async_runtime::spawn_blocking(move || file_index::search(&query, limit.unwrap_or(50)))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("搜索任务失败: {}", e))
|
.map_err(|e| format!("搜索任务失败: {}", e))
|
||||||
@@ -135,7 +250,9 @@ pub async fn quickpanel_search_files(
|
|||||||
/// 获取索引状态
|
/// 获取索引状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn quickpanel_file_index_stats() -> file_index::IndexStats {
|
pub async fn quickpanel_file_index_stats(app: AppHandle) -> file_index::IndexStats {
|
||||||
|
// 懒加载:查询状态前确保 DB 已初始化(未初始化时 stats 返回全 0)
|
||||||
|
file_index::ensure_initialized(&app);
|
||||||
file_index::stats()
|
file_index::stats()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +415,69 @@ fn delete_file_impl(path: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 批量删除文件/目录。`force=false` 时移动至回收站;`force=true` 时先递归清除
|
||||||
|
/// 只读属性再永久删除(可绕过只读/部分占用导致的删除失败,但被其他进程真正
|
||||||
|
/// 锁定的文件仍会失败并返回原因)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn quickpanel_delete_files(
|
||||||
|
paths: Vec<String>,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<Vec<DeleteResult>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let name = std::path::Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.clone());
|
||||||
|
let result = if force {
|
||||||
|
delete_force_impl(&path)
|
||||||
|
} else {
|
||||||
|
delete_file_impl(&path)
|
||||||
|
};
|
||||||
|
out.push(DeleteResult {
|
||||||
|
name,
|
||||||
|
path: path.clone(),
|
||||||
|
ok: result.is_ok(),
|
||||||
|
error: result.err().unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("删除任务失败: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强行删除:递归清除只读属性后永久删除(不经过回收站)。
|
||||||
|
fn delete_force_impl(path: &str) -> Result<(), String> {
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
clear_readonly(p);
|
||||||
|
if p.is_dir() {
|
||||||
|
std::fs::remove_dir_all(p).map_err(|e| format!("删除失败: {e}"))
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归清除只读属性(只读文件/目录无法直接删除)。
|
||||||
|
fn clear_readonly(path: &std::path::Path) {
|
||||||
|
if let Ok(meta) = std::fs::metadata(path) {
|
||||||
|
if meta.permissions().readonly() {
|
||||||
|
let mut perms = meta.permissions();
|
||||||
|
perms.set_readonly(false);
|
||||||
|
let _ = std::fs::set_permissions(path, perms);
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(path) {
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
clear_readonly(&entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 运行自定义命令(执行可执行文件 + 参数)
|
/// 运行自定义命令(执行可执行文件 + 参数)
|
||||||
/// .lnk 快捷方式不能直接 spawn(os error 193),需通过 cmd /C 启动
|
/// .lnk 快捷方式不能直接 spawn(os error 193),需通过 cmd /C 启动
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -325,11 +505,35 @@ pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Resu
|
|||||||
|
|
||||||
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
||||||
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
||||||
|
/// - 控制台类交互程序(cmd/powershell/pwsh)额外设置 CREATE_NEW_CONSOLE,
|
||||||
|
/// 否则从 GUI 宿主启动时无可见控制台窗口(表现为"点击没反应")。
|
||||||
|
/// - .msc 控制台文件(如 devmgmt.msc)不可被 CreateProcess 直接执行,
|
||||||
|
/// 改由 mmc 打开(路径解析到 System32,不受当前工作目录影响)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
||||||
|
let lower = command.to_lowercase();
|
||||||
|
if lower.ends_with(".msc") {
|
||||||
|
// 控制台文件:通过 mmc 打开(GUI 程序,无需新控制台)
|
||||||
|
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||||
|
let path = format!("{}\\System32\\{}", system_root, command);
|
||||||
|
let mut c = std::process::Command::new("mmc");
|
||||||
|
c.arg(&path);
|
||||||
|
return c
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("运行系统命令失败: {}", e));
|
||||||
|
}
|
||||||
let mut cmd = std::process::Command::new(&command);
|
let mut cmd = std::process::Command::new(&command);
|
||||||
cmd.args(&args);
|
cmd.args(&args);
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
let c = lower;
|
||||||
|
if c == "cmd" || c == "powershell" || c == "pwsh" {
|
||||||
|
cmd.creation_flags(crate::process_manager::CREATE_NEW_CONSOLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! 快速面板:检测前台 Explorer 窗口的当前目录。
|
||||||
|
//!
|
||||||
|
//! 必须在快捷键回调(`show_popup`)内调用:此时前台窗口仍是资源管理器,
|
||||||
|
//! 面板尚未取得焦点,`GetForegroundWindow` 拿到的才是 Explorer 主窗口;
|
||||||
|
//! 若等面板显示后再调用,前台就变成面板自身了。
|
||||||
|
//!
|
||||||
|
//! 思路(Listary / PowerToys Run 同款):前台窗口 HWND 匹配 `IShellWindows`
|
||||||
|
//! 中某个 Shell 窗口 → 取其 `LocationURL`(file:///...)→ 转成本地路径。
|
||||||
|
//!
|
||||||
|
//! Win11 多选项卡:同一顶层窗口下每个选项卡都是独立的 `IShellWindows` 条目,
|
||||||
|
//! 共享顶层 HWND。活动选项卡的内容窗口(`ShellTabWindowClass`)在子窗口
|
||||||
|
//! z-order 顶层,用 `IID_IShellBrowser` 作为 `QueryService` 的 service ID 获取
|
||||||
|
//! 每个选项卡自己的 `IShellBrowser`(而非 `SID_STopLevelBrowser` 返回的顶层
|
||||||
|
//! browser),再通过 `GetWindow()` 拿到该选项卡的内容窗口句柄,与活动选项卡
|
||||||
|
//! 的内容窗口比对,从而定位当前正在浏览的选项卡(`IsWindowVisible` 对所有
|
||||||
|
//! 选项卡都成立,不可用)。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::core::ComInterface;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::Foundation::HWND;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::System::Com::{
|
||||||
|
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED,
|
||||||
|
IServiceProvider,
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::System::Variant::{VARIANT, VT_I4};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::UI::Shell::{
|
||||||
|
IWebBrowserApp, IShellBrowser, IShellWindows, ShellWindows,
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetClassNameW, GetForegroundWindow, GetWindow, GW_CHILD, GW_HWNDNEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 检测前台 Explorer 窗口的当前目录。
|
||||||
|
/// 返回 `None`:前台不是 Explorer / COM 初始化失败 / URL 无法转路径。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn detect_explorer_folder() -> Option<String> {
|
||||||
|
// 首次 COM 初始化失败(例如已在 MTA 线程)时,后续 COM 调用一般仍可用,忽略错误继续。
|
||||||
|
let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||||
|
let fg = unsafe { GetForegroundWindow() };
|
||||||
|
let result = if fg.0 == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
unsafe { find_folder_for_hwnd(fg) }
|
||||||
|
};
|
||||||
|
unsafe { CoUninitialize() };
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn find_folder_for_hwnd(fg: HWND) -> Option<String> {
|
||||||
|
let shell: IShellWindows = CoCreateInstance(&ShellWindows, None, CLSCTX_ALL).ok()?;
|
||||||
|
let count = shell.Count().ok()?;
|
||||||
|
// Win11 多选项卡:活动选项卡的内容窗口(ShellTabWindowClass)在子窗口 z-order
|
||||||
|
// 顶层。在 IShellWindows 条目中,用 IShellBrowser::GetWindow() 取到的内容窗口
|
||||||
|
// HWND 与它比对,即可定位当前正在浏览的选项卡(IsWindowVisible 对所有选项卡
|
||||||
|
// 都成立,不可用)。
|
||||||
|
let active_tab = find_active_shell_tab(fg);
|
||||||
|
for i in 0..count {
|
||||||
|
// 索引过期的窗口会返回失败,跳过继续即可,不能 `?` 提前结束整个循环。
|
||||||
|
// 0.52 的 Win32 VARIANT 无 From<i32>,手动构造 VT_I4 变体。
|
||||||
|
let mut index = VARIANT::default();
|
||||||
|
{
|
||||||
|
let value = &mut *index.Anonymous.Anonymous;
|
||||||
|
value.vt = VT_I4;
|
||||||
|
value.Anonymous.lVal = i;
|
||||||
|
}
|
||||||
|
let Ok(dispatch) = shell.Item(index) else { continue };
|
||||||
|
let Ok(app) = dispatch.cast::<IWebBrowserApp>() else { continue };
|
||||||
|
// 只考虑前台顶层窗口对应的条目;同一窗口的多个选项卡条目共享顶层句柄。
|
||||||
|
let Ok(hwnd) = app.HWND() else { continue };
|
||||||
|
if HWND(hwnd.0) != fg {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 有选项卡时,必须匹配活动选项卡的内容窗口;否则退化为任意条目(旧版无选项卡)。
|
||||||
|
if let Some(active) = active_tab {
|
||||||
|
let Ok(svc) = app.cast::<IServiceProvider>() else { continue };
|
||||||
|
let Ok(browser) = svc.QueryService::<IShellBrowser>(&IShellBrowser::IID) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(this_tab) = browser.GetWindow() else { continue };
|
||||||
|
if this_tab != active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(url) = app.LocationURL() {
|
||||||
|
return url_to_path(&url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 枚举前台窗口的子窗口(z-order 自上而下),返回第一个类名为 `ShellTabWindowClass`
|
||||||
|
/// 的窗口句柄,即 Win11 资源管理器活动选项卡的内容窗口;无选项卡时返回 `None`。
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn find_active_shell_tab(fg: HWND) -> Option<HWND> {
|
||||||
|
const CLASS: &str = "ShellTabWindowClass";
|
||||||
|
let mut hwnd = GetWindow(fg, GW_CHILD);
|
||||||
|
while hwnd.0 != 0 {
|
||||||
|
let mut buf = [0u16; 64];
|
||||||
|
let len = GetClassNameW(hwnd, &mut buf);
|
||||||
|
if len > 0 {
|
||||||
|
let name = String::from_utf16_lossy(&buf[..len as usize]);
|
||||||
|
if name == CLASS {
|
||||||
|
return Some(hwnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hwnd = GetWindow(hwnd, GW_HWNDNEXT);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `file:///C:/xxx`(可能带百分号编码)转成本地路径,仅接受目录。
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn url_to_path(url: &str) -> Option<String> {
|
||||||
|
if !url.starts_with("file:") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let parsed = url::Url::parse(url).ok()?;
|
||||||
|
let path = parsed.to_file_path().ok()?;
|
||||||
|
let path = Path::new(&path);
|
||||||
|
if path.is_dir() {
|
||||||
|
Some(path.to_string_lossy().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 Windows 平台占位:保持模块可编译。
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn detect_explorer_folder() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
@@ -93,6 +94,17 @@ pub fn init(app: &AppHandle) {
|
|||||||
crate::logger::log_info("quickpanel", &format!("文件索引 DB 已就绪: {}", path.display()));
|
crate::logger::log_info("quickpanel", &format!("文件索引 DB 已就绪: {}", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 懒加载:首次访问文件索引时自动初始化(若尚未初始化)。
|
||||||
|
/// 避免应用启动时即打开 SQLite 连接,降低启动 IO 开销。
|
||||||
|
pub fn ensure_initialized(app: &AppHandle) {
|
||||||
|
let guard = index_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if guard.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
init(app);
|
||||||
|
}
|
||||||
|
|
||||||
/// 判断索引是否已初始化
|
/// 判断索引是否已初始化
|
||||||
fn with_conn<F, R>(f: F) -> Option<R>
|
fn with_conn<F, R>(f: F) -> Option<R>
|
||||||
where
|
where
|
||||||
@@ -107,10 +119,37 @@ where
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建锁:防止手动构建与闲时自动构建并发执行(全量重建含 DELETE+INSERT,
|
||||||
|
/// 两个构建交错会互相清空对方刚写入的数据,导致索引残缺)。
|
||||||
|
static BUILDING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 尝试开始构建;已在构建中则返回 false。
|
||||||
|
pub fn try_begin_build() -> bool {
|
||||||
|
BUILDING
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建结束(成功/失败)后调用,释放构建锁。
|
||||||
|
pub fn end_build() {
|
||||||
|
BUILDING.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
/// 遍历指定目录列表建立索引(全量重建)。
|
/// 遍历指定目录列表建立索引(全量重建)。
|
||||||
/// 返回索引条目数。在 spawn_blocking 中调用。
|
/// 返回索引条目数。在 spawn_blocking 中调用。
|
||||||
/// 重建完成后自动启动 notify 监听器做增量更新。
|
/// 重建完成后自动启动 notify 监听器做增量更新。
|
||||||
|
/// 若已有构建正在进行(手动/自动并发),直接返回 Ok(0),由进行中的构建负责更新索引。
|
||||||
pub fn build_index(dirs: &[String]) -> Result<i64, String> {
|
pub fn build_index(dirs: &[String]) -> Result<i64, String> {
|
||||||
|
if !try_begin_build() {
|
||||||
|
crate::logger::log_info("quickpanel", "索引构建已在进行中,跳过本次请求");
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let result = build_index_inner(dirs);
|
||||||
|
end_build();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_index_inner(dirs: &[String]) -> Result<i64, String> {
|
||||||
// 清空旧数据
|
// 清空旧数据
|
||||||
let cleared = with_conn(|conn| {
|
let cleared = with_conn(|conn| {
|
||||||
conn.execute("DELETE FROM files", []).ok()
|
conn.execute("DELETE FROM files", []).ok()
|
||||||
@@ -333,16 +372,51 @@ pub fn start_watcher(dirs: &[String]) {
|
|||||||
crate::logger::log_info("quickpanel", &format!("notify 监听已启动,监听 {} 个目录", dirs.len()));
|
crate::logger::log_info("quickpanel", &format!("notify 监听已启动,监听 {} 个目录", dirs.len()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 处理文件系统事件:创建/修改 → upsert,删除 → remove,重命名 → remove + upsert
|
/// 处理文件系统事件:
|
||||||
|
/// - 创建/数据或元数据修改 → upsert
|
||||||
|
/// - 重命名:旧路径(From) → remove,新路径(To) → upsert
|
||||||
|
/// - 删除 → remove
|
||||||
fn handle_fs_event(event: ¬ify::Event) {
|
fn handle_fs_event(event: ¬ify::Event) {
|
||||||
|
use notify::event::{ModifyKind, RenameMode};
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
EventKind::Create(_) | EventKind::Modify(_) => {
|
// 创建、数据/元数据修改、类型未知 → upsert
|
||||||
|
EventKind::Create(_)
|
||||||
|
| EventKind::Modify(ModifyKind::Data(_))
|
||||||
|
| EventKind::Modify(ModifyKind::Metadata(_))
|
||||||
|
| EventKind::Modify(ModifyKind::Other)
|
||||||
|
| EventKind::Modify(ModifyKind::Any) => {
|
||||||
for path in &event.paths {
|
for path in &event.paths {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
upsert_path(path);
|
upsert_path(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 重命名旧路径 → 删除旧记录(含目录子项,避免索引残留失效路径)
|
||||||
|
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
|
||||||
|
for path in &event.paths {
|
||||||
|
remove_path(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重命名新路径 → 写入新记录
|
||||||
|
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
|
||||||
|
for path in &event.paths {
|
||||||
|
if path.exists() {
|
||||||
|
upsert_path(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重命名模式未知:存在则写入,不存在则删除(幂等兜底)
|
||||||
|
EventKind::Modify(ModifyKind::Name(RenameMode::Any | RenameMode::Both)) => {
|
||||||
|
for path in &event.paths {
|
||||||
|
if path.exists() {
|
||||||
|
upsert_path(path);
|
||||||
|
} else {
|
||||||
|
remove_path(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 删除 → remove(含子项)
|
||||||
EventKind::Remove(_) => {
|
EventKind::Remove(_) => {
|
||||||
for path in &event.paths {
|
for path in &event.paths {
|
||||||
remove_path(path);
|
remove_path(path);
|
||||||
|
|||||||
@@ -4,21 +4,27 @@
|
|||||||
//! Phase 2:fuzzy + 拼音引擎,command/calc/web/system Provider
|
//! Phase 2:fuzzy + 拼音引擎,command/calc/web/system Provider
|
||||||
//! Phase 3:文件索引(walkdir + rusqlite)、应用扫描、剪贴板历史复用
|
//! Phase 3:文件索引(walkdir + rusqlite)、应用扫描、剪贴板历史复用
|
||||||
|
|
||||||
|
pub mod actions;
|
||||||
pub mod app_scanner;
|
pub mod app_scanner;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod explorer;
|
||||||
pub mod file_index;
|
pub mod file_index;
|
||||||
pub mod icon_extractor;
|
pub mod icon_extractor;
|
||||||
pub mod popup;
|
pub mod popup;
|
||||||
pub mod special_locations;
|
pub mod special_locations;
|
||||||
|
|
||||||
|
pub use actions::{
|
||||||
|
quickpanel_apply_rename, quickpanel_batch_extract, quickpanel_list_archives,
|
||||||
|
quickpanel_list_dir, quickpanel_preview_rename,
|
||||||
|
};
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
||||||
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
quickpanel_delete_files, quickpanel_file_index_stats, quickpanel_focus_main_window,
|
||||||
quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index,
|
quickpanel_get_app_icon, quickpanel_get_settings, quickpanel_get_special_locations,
|
||||||
quickpanel_lock_screen, quickpanel_open_file, quickpanel_open_special,
|
quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen,
|
||||||
quickpanel_register_shortcut, quickpanel_reveal_in_explorer, quickpanel_run_custom_command,
|
quickpanel_open_file, quickpanel_open_special, quickpanel_register_shortcut,
|
||||||
quickpanel_run_system_command, quickpanel_save_settings, quickpanel_scan_apps,
|
quickpanel_reveal_in_explorer, quickpanel_run_custom_command, quickpanel_run_system_command,
|
||||||
quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
|
quickpanel_save_settings, quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup,
|
||||||
quickpanel_unregister_shortcut,
|
quickpanel_show_window, quickpanel_unregister_shortcut,
|
||||||
};
|
};
|
||||||
pub use popup::{ensure_window, load_settings};
|
pub use popup::{ensure_window, load_settings};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
@@ -20,6 +21,8 @@ use tauri::window::{Effect, EffectsBuilder};
|
|||||||
|
|
||||||
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
|
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
|
||||||
|
|
||||||
|
use super::explorer;
|
||||||
|
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
/// 弹窗窗口标签
|
/// 弹窗窗口标签
|
||||||
@@ -29,6 +32,24 @@ pub const POPUP_LABEL: &str = "quick-panel";
|
|||||||
const WIN_W: f64 = 600.0;
|
const WIN_W: f64 = 600.0;
|
||||||
const WIN_H: f64 = 420.0;
|
const WIN_H: f64 = 420.0;
|
||||||
|
|
||||||
|
/// `quickpanel-show` 事件负载:携带快捷键按下时检测到的 Explorer 当前目录,
|
||||||
|
/// 前端据此渲染"当前目录"文件操作分组。
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct QuickPanelShowPayload {
|
||||||
|
pub dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测前台 Explorer 目录并连同 show 事件一起下发。
|
||||||
|
/// 必须在快捷键回调内调用:此时前台窗口仍是 Explorer,面板尚未抢焦点。
|
||||||
|
fn emit_show(app: &AppHandle) {
|
||||||
|
let dir = explorer::detect_explorer_folder();
|
||||||
|
crate::logger::log_info("quickpanel", &format!("show_popup: explorer_dir={:?}", dir));
|
||||||
|
let _ = app.emit(
|
||||||
|
crate::constants::events::QUICKPANEL_SHOW,
|
||||||
|
QuickPanelShowPayload { dir },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
|
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
|
||||||
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
|
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
|
||||||
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
||||||
@@ -37,6 +58,48 @@ static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
|||||||
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
|
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
|
||||||
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
|
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 失焦隐藏的宽限期:show 后窗口激活期间焦点可能短暂弹跳(透明 + focus:false 的
|
||||||
|
/// WebView2 窗口在透明激活时尤其容易出现),导致 Focused(false) 紧跟在 show 之后
|
||||||
|
/// 触发并把刚显示的窗口立即隐藏。距上次 show 不足该时长的失焦事件直接忽略。
|
||||||
|
const SHOW_GRACE: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// 最近一次 show 的时间,用于失焦宽限期判断。
|
||||||
|
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
|
||||||
|
fn mark_shown() {
|
||||||
|
if let Ok(mut t) = LAST_SHOWN.lock() {
|
||||||
|
*t = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
|
||||||
|
fn within_show_grace() -> bool {
|
||||||
|
LAST_SHOWN
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| *t)
|
||||||
|
.map(|t| t.elapsed() < SHOW_GRACE)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示窗口并确保其到达前台。
|
||||||
|
/// set_focus 受 Windows 前台锁定限制,可能静默失败;此时用 win32_util::force_foreground
|
||||||
|
/// (模拟 Alt 释放重置前台锁定 + SetForegroundWindow + BringWindowToTop)兜底。
|
||||||
|
fn show_and_focus(win: &tauri::WebviewWindow) {
|
||||||
|
if let Err(e) = win.show() {
|
||||||
|
crate::logger::log_error("quickpanel", &format!("show popup failed: {}", e));
|
||||||
|
}
|
||||||
|
mark_shown();
|
||||||
|
let focused = win.set_focus();
|
||||||
|
if focused.is_err() {
|
||||||
|
// set_focus 被前台锁定拒绝时,退回到强制置前(与主窗口焦点命令同法)
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::force_foreground(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 自定义命令
|
/// 自定义命令
|
||||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -164,10 +227,14 @@ fn create_popup_window(app: &AppHandle) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 监听窗口失焦:自动隐藏
|
// 监听窗口失焦:自动隐藏
|
||||||
|
// 距上次 show 不足宽限期(激活中焦点弹跳)的失焦事件忽略,避免弹窗刚显示就被隐藏
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
let win_handle = win.clone();
|
let win_handle = win.clone();
|
||||||
win.on_window_event(move |event| {
|
win.on_window_event(move |event| {
|
||||||
if let tauri::WindowEvent::Focused(false) = event {
|
if let tauri::WindowEvent::Focused(false) = event {
|
||||||
|
if within_show_grace() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let _ = win_handle.hide();
|
let _ = win_handle.hide();
|
||||||
let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ());
|
let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ());
|
||||||
}
|
}
|
||||||
@@ -189,6 +256,7 @@ pub fn ensure_window(app: &AppHandle) {
|
|||||||
/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。
|
/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。
|
||||||
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
||||||
pub fn show_popup(app: &AppHandle) {
|
pub fn show_popup(app: &AppHandle) {
|
||||||
|
crate::logger::log_info("quickpanel", "show_popup triggered");
|
||||||
let settings = load_settings(app);
|
let settings = load_settings(app);
|
||||||
let cursor_mode = settings.popup_position == "cursor";
|
let cursor_mode = settings.popup_position == "cursor";
|
||||||
|
|
||||||
@@ -226,14 +294,14 @@ pub fn show_popup(app: &AppHandle) {
|
|||||||
|
|
||||||
// 窗口已存在:移动 + 显示 + 请求焦点
|
// 窗口已存在:移动 + 显示 + 请求焦点
|
||||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
// 必须先于窗口显示/聚焦检测 Explorer 目录:show/set_focus 会立即抢走前台焦点,
|
||||||
|
// 之后调用 GetForegroundWindow 拿到的就是面板自身了。
|
||||||
|
emit_show(app);
|
||||||
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
|
||||||
x: x as i32,
|
x: x as i32,
|
||||||
y: y as i32,
|
y: y as i32,
|
||||||
}));
|
}));
|
||||||
let _ = win.show();
|
show_and_focus(&win);
|
||||||
let _ = win.set_focus();
|
|
||||||
// 通知前端刷新数据
|
|
||||||
let _ = app.emit(crate::constants::events::QUICKPANEL_SHOW, ());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,12 +314,24 @@ pub fn show_popup(app: &AppHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
|
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
|
||||||
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过,
|
///
|
||||||
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。
|
/// 两个路径:
|
||||||
|
/// 1. 预创建路径(POPUP_PENDING_SHOW = false):show_popup 已调用 show_and_focus 显示窗口,
|
||||||
|
/// 但若 Vue 尚未挂载,quickpanel-show 事件可能丢失。检查窗口是否可见,若可见则重新发送事件。
|
||||||
|
/// 2. 兜底创建路径(POPUP_PENDING_SHOW = true):窗口尚未显示,位置为 PENDING_POS,
|
||||||
|
/// 先定位再发事件最后显示+聚焦。
|
||||||
pub fn show_window(app: &AppHandle) {
|
pub fn show_window(app: &AppHandle) {
|
||||||
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
|
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
|
||||||
|
// 预创建路径:窗口已由 show_popup 显示,但事件可能因前端未挂载而丢失。
|
||||||
|
// 窗口可见时重新发送事件,让刚注册的监听器处理(清空输入、聚焦、刷新等)。
|
||||||
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
if win.is_visible().unwrap_or(false) {
|
||||||
|
emit_show(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 兜底创建路径:show_popup 兜底重建,窗口尚未显示
|
||||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
// 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外
|
// 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外
|
||||||
let pos = PENDING_POS.lock().ok().and_then(|p| *p);
|
let pos = PENDING_POS.lock().ok().and_then(|p| *p);
|
||||||
@@ -261,10 +341,9 @@ pub fn show_window(app: &AppHandle) {
|
|||||||
y: y as i32,
|
y: y as i32,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let _ = win.show();
|
// 同样先检测 Explorer 目录再显示,避免面板抢焦点导致检测失败。
|
||||||
let _ = win.set_focus();
|
emit_show(app);
|
||||||
// 通知前端刷新数据
|
show_and_focus(&win);
|
||||||
let _ = app.emit(crate::constants::events::QUICKPANEL_SHOW, ());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
//! 实现:
|
//! 实现:
|
||||||
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
|
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
|
||||||
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
|
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
|
||||||
//! - 窗口拾取:EnumWindows 按 Z 序命中测试(排除本进程窗口,避免命中覆盖层自身)
|
//! - 窗口拾取:pick_windows 枚举 Z 序窗口列表(排除本进程,避免命中覆盖层自身),
|
||||||
|
//! 前端缓存列表后本地命中测试
|
||||||
//! - 顶层窗口枚举:EnumWindows
|
//! - 顶层窗口枚举:EnumWindows
|
||||||
//! - 像素 → PNG / CF_DIB 转换
|
//! - 像素 → PNG / CF_DIB 转换
|
||||||
//!
|
//!
|
||||||
@@ -17,9 +18,9 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use windows_sys::Win32::Foundation::{BOOL, HWND, POINT, RECT};
|
use windows_sys::Win32::Foundation::{BOOL, HWND, POINT, RECT};
|
||||||
use windows_sys::Win32::Graphics::Gdi::{
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
|
BitBlt, CombineRgn, CreateCompatibleBitmap, CreateCompatibleDC, CreateRectRgn, DeleteDC,
|
||||||
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
|
DeleteObject, GetDC, GetDIBits, PatBlt, ReleaseDC, SelectObject, SetWindowRgn, BITMAPINFO,
|
||||||
RGBQUAD, SRCCOPY,
|
BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS, RGBQUAD, RGN_DIFF, SRCCOPY,
|
||||||
};
|
};
|
||||||
use windows_sys::Win32::Storage::Xps::PrintWindow;
|
use windows_sys::Win32::Storage::Xps::PrintWindow;
|
||||||
use windows_sys::Win32::System::DataExchange::{
|
use windows_sys::Win32::System::DataExchange::{
|
||||||
@@ -33,7 +34,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
|
|||||||
WS_EX_TOOLWINDOW,
|
WS_EX_TOOLWINDOW,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{CaptureData, ScreenRect, WindowInfo};
|
use super::{CaptureData, ScreenRect, ScrollRegion, WindowInfo};
|
||||||
|
|
||||||
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
|
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
|
||||||
pub struct CapturedImage {
|
pub struct CapturedImage {
|
||||||
@@ -110,7 +111,7 @@ fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String>
|
|||||||
///
|
///
|
||||||
/// 使用 Fast 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比,
|
/// 使用 Fast 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比,
|
||||||
/// 大幅降低"点击完成 → 关闭窗口"的编码延迟。
|
/// 大幅降低"点击完成 → 关闭窗口"的编码延迟。
|
||||||
fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
|
pub(crate) fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
|
||||||
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
|
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
|
||||||
use image::ImageEncoder;
|
use image::ImageEncoder;
|
||||||
if width <= 0 || height <= 0 {
|
if width <= 0 || height <= 0 {
|
||||||
@@ -137,7 +138,7 @@ fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 从 HBITMAP 提取 32bpp BGRA top-down 像素
|
/// 从 HBITMAP 提取 32bpp BGRA top-down 像素
|
||||||
unsafe fn extract_pixels(
|
pub(crate) unsafe fn extract_pixels(
|
||||||
hdc_mem: isize,
|
hdc_mem: isize,
|
||||||
hbm: isize,
|
hbm: isize,
|
||||||
width: i32,
|
width: i32,
|
||||||
@@ -181,7 +182,16 @@ unsafe fn extract_pixels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
|
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
|
||||||
|
///
|
||||||
|
/// 优先走 BitBlt(SDR 全屏一次捕获、低延迟);检测到任一显示器为 HDR 时改用 WGC
|
||||||
|
/// (RGBA16F + HDR→sRGB 色调映射,避免 BitBlt 把 scRGB 线性像素当 sRGB 直出导致过曝)。
|
||||||
|
/// WGC 失败时回退到 BitBlt。
|
||||||
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
|
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
|
||||||
|
if super::wgc_capture::is_hdr_enabled() {
|
||||||
|
if let Ok(img) = super::wgc_capture::capture_virtual_screen_wgc() {
|
||||||
|
return Ok(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
unsafe {
|
unsafe {
|
||||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||||
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||||
@@ -231,6 +241,19 @@ pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
|
|||||||
|
|
||||||
/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口)
|
/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口)
|
||||||
pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
|
pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
|
||||||
|
let img = capture_window_bgra(hwnd)?;
|
||||||
|
let png = bgra_to_png(&img.bgra, img.width, img.height)?;
|
||||||
|
Ok(CapturedImage {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
png,
|
||||||
|
bgra: img.bgra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获指定窗口的原始 BGRA(不做 PNG 编码)。
|
||||||
|
/// 滚动截图每帧只需像素数据做匹配拼接,跳过编码可显著降低单帧耗时。
|
||||||
|
pub fn capture_window_bgra(hwnd: isize) -> Result<CapturedImage, String> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut rect: RECT = std::mem::zeroed();
|
let mut rect: RECT = std::mem::zeroed();
|
||||||
if GetWindowRect(hwnd, &mut rect) == 0 {
|
if GetWindowRect(hwnd, &mut rect) == 0 {
|
||||||
@@ -258,13 +281,11 @@ pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
|
|||||||
let result = if ok == 0 {
|
let result = if ok == 0 {
|
||||||
Err("PrintWindow 失败(可能窗口无响应或权限不足)".into())
|
Err("PrintWindow 失败(可能窗口无响应或权限不足)".into())
|
||||||
} else {
|
} else {
|
||||||
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
|
extract_pixels(hdc_mem, hbm, w, h).map(|bgra| CapturedImage {
|
||||||
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
|
width: w,
|
||||||
width: w,
|
height: h,
|
||||||
height: h,
|
png: Vec::new(),
|
||||||
png,
|
bgra,
|
||||||
bgra,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,18 +297,16 @@ pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取)
|
/// 枚举可拾取的顶层窗口(Z 序顶→底)
|
||||||
///
|
///
|
||||||
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。
|
/// 与逐点拾取同语义:排除本进程窗口(覆盖层/主窗口/编辑器)、不可见窗口、工具窗口。
|
||||||
///
|
/// 前端在截图开始时缓存该列表,鼠标移动时在 JS 侧做命中测试(rect 包含点,取 Z 序
|
||||||
/// 不能直接用 WindowFromPoint:覆盖层是 alwaysOnTop 全屏窗口,会命中覆盖层自身。
|
/// 最顶的第一个命中),消除逐帧 window_from_point 的 IPC 往返;且列表与冻结底图
|
||||||
/// 改为 EnumWindows 按 Z 序(顶→底)枚举顶层窗口做命中测试,并排除本进程
|
/// 同一时刻生成,命中结果与画面严格一致。
|
||||||
/// (覆盖层/主窗口/编辑器)的窗口,从而取到覆盖层下面的目标窗口。
|
pub fn pick_windows() -> Vec<WindowInfo> {
|
||||||
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
|
|
||||||
struct PickContext {
|
struct PickContext {
|
||||||
my_pid: u32,
|
my_pid: u32,
|
||||||
pt: POINT,
|
out: Vec<WindowInfo>,
|
||||||
found: Option<WindowInfo>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
|
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
|
||||||
@@ -311,30 +330,24 @@ pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
|
|||||||
if GetWindowRect(hwnd, &mut rect) == 0 {
|
if GetWindowRect(hwnd, &mut rect) == 0 {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
// 命中测试(物理坐标),Z 序最顶层的第一个命中即为目标
|
ctx.out.push(WindowInfo {
|
||||||
let pt = ctx.pt;
|
hwnd,
|
||||||
if pt.x >= rect.left && pt.x < rect.right && pt.y >= rect.top && pt.y < rect.bottom {
|
title: get_window_title(hwnd),
|
||||||
ctx.found = Some(WindowInfo {
|
rect: ScreenRect::from(rect),
|
||||||
hwnd,
|
visual_rect: extended_frame_bounds(hwnd),
|
||||||
title: get_window_title(hwnd),
|
});
|
||||||
rect: ScreenRect::from(rect),
|
|
||||||
visual_rect: extended_frame_bounds(hwnd),
|
|
||||||
});
|
|
||||||
return 0; // 停止枚举
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ctx = PickContext {
|
let mut ctx = PickContext {
|
||||||
my_pid: std::process::id(),
|
my_pid: std::process::id(),
|
||||||
pt: POINT { x, y },
|
out: Vec::new(),
|
||||||
found: None,
|
|
||||||
};
|
};
|
||||||
unsafe {
|
unsafe {
|
||||||
EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize);
|
EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize);
|
||||||
}
|
}
|
||||||
ctx.found
|
ctx.out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
|
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
|
||||||
@@ -393,6 +406,71 @@ pub fn disable_window_transitions(hwnd: isize) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 滚动截图模式:在覆盖层窗口上挖出选区带的"真孔"(内缩 INSET 保留蓝框),或清除恢复整窗。
|
||||||
|
///
|
||||||
|
/// 背景:Chromium 系浏览器(Edge/Chrome)的原生窗口遮挡检测(occlusion tracking)
|
||||||
|
/// 会把被**完全覆盖**的窗口标记为 occluded 并暂停渲染合成——滚动截图时覆盖层
|
||||||
|
/// 铺满全屏盖住目标窗口,网页"看起来完全不滚动",PrintWindow 抓到的也是静止帧。
|
||||||
|
/// 覆盖层并非 layered 窗口,即使其内容视觉透明,窗口矩形对遮挡检测仍算不透明覆盖。
|
||||||
|
/// 挖孔后目标窗口仅部分被覆盖(Chromium 要求完全覆盖才判 occluded,实测 60x60
|
||||||
|
/// 的小孔即可解除),恢复渲染与滚轮响应,拼接匹配随之正常。
|
||||||
|
///
|
||||||
|
/// - `region = Some`:孔 = 选区带内缩 INSET(保留 2px 蓝框 + 1px 白描边);
|
||||||
|
/// 选区过小(< 2*INSET+8)时不挖孔,避免退化区域
|
||||||
|
/// - `region = None`:SetWindowRgn(NULL) 清除窗口区域(恢复整窗)
|
||||||
|
///
|
||||||
|
/// 坐标:region 为屏幕物理像素(与滚动会话同源),按覆盖层窗口原点换算成窗口坐标。
|
||||||
|
/// 区域设置在窗口上持续有效,会话结束/新一轮截图开始时必须传 None 复位。
|
||||||
|
pub fn set_scroll_hole(hwnd: isize, region: Option<ScrollRegion>) -> Result<(), String> {
|
||||||
|
unsafe {
|
||||||
|
match region {
|
||||||
|
Some(r) => {
|
||||||
|
let mut wr: RECT = std::mem::zeroed();
|
||||||
|
if GetWindowRect(hwnd, &mut wr) == 0 {
|
||||||
|
return Err("GetWindowRect 失败".into());
|
||||||
|
}
|
||||||
|
const INSET: i32 = 4;
|
||||||
|
let hx1 = r.x - wr.left + INSET;
|
||||||
|
let hy1 = r.y - wr.top + INSET;
|
||||||
|
let hx2 = r.x + r.width - wr.left - INSET;
|
||||||
|
let hy2 = r.y + r.height - wr.top - INSET;
|
||||||
|
if hx2 - hx1 < 8 || hy2 - hy1 < 8 {
|
||||||
|
// 选区太小:不挖孔(保持整窗)
|
||||||
|
if SetWindowRgn(hwnd, 0, 1) == 0 {
|
||||||
|
return Err("SetWindowRgn 失败".into());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let full = CreateRectRgn(0, 0, wr.right - wr.left, wr.bottom - wr.top);
|
||||||
|
let hole = CreateRectRgn(hx1, hy1, hx2, hy2);
|
||||||
|
if full == 0 || hole == 0 {
|
||||||
|
if full != 0 {
|
||||||
|
DeleteObject(full);
|
||||||
|
}
|
||||||
|
if hole != 0 {
|
||||||
|
DeleteObject(hole);
|
||||||
|
}
|
||||||
|
return Err("CreateRectRgn 失败".into());
|
||||||
|
}
|
||||||
|
CombineRgn(full, full, hole, RGN_DIFF);
|
||||||
|
// 组合结果 full 归 SetWindowRgn 所有;hole 用完即删
|
||||||
|
DeleteObject(hole);
|
||||||
|
if SetWindowRgn(hwnd, full, 1) == 0 {
|
||||||
|
DeleteObject(full);
|
||||||
|
return Err("SetWindowRgn 失败".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if SetWindowRgn(hwnd, 0, 1) == 0 {
|
||||||
|
return Err("SetWindowRgn 失败".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
|
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
|
||||||
pub fn enum_visible_windows() -> Vec<WindowInfo> {
|
pub fn enum_visible_windows() -> Vec<WindowInfo> {
|
||||||
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
|
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
|
||||||
@@ -530,7 +608,7 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
|
|||||||
dib
|
dib
|
||||||
}
|
}
|
||||||
|
|
||||||
fn crop_bgra(
|
pub(crate) fn crop_bgra(
|
||||||
src: &[u8],
|
src: &[u8],
|
||||||
src_width: usize,
|
src_width: usize,
|
||||||
x: i32,
|
x: i32,
|
||||||
@@ -674,6 +752,20 @@ pub fn compose_copy_rgba(rgba: &[u8], width: i32, height: i32) -> Result<Capture
|
|||||||
}
|
}
|
||||||
let dib = rgba_raw_to_dib(rgba, width, height);
|
let dib = rgba_raw_to_dib(rgba, width, height);
|
||||||
write_dib_to_clipboard(&dib)?;
|
write_dib_to_clipboard(&dib)?;
|
||||||
|
rgba_raw_to_png_checked(rgba, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有标注导出:raw RGBA → PNG base64(仅编码,不写剪贴板;编辑器「保存到文件」用)
|
||||||
|
pub fn compose_png_rgba(rgba: &[u8], width: i32, height: i32) -> Result<CaptureData, String> {
|
||||||
|
let expected = (width as usize) * (height as usize) * 4;
|
||||||
|
if rgba.len() < expected {
|
||||||
|
return Err(format!("像素数据不足: {} < {}", rgba.len(), expected));
|
||||||
|
}
|
||||||
|
rgba_raw_to_png_checked(rgba, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// raw RGBA → PNG base64(带长度校验的封装,供 compose_copy/compose_png 共用)
|
||||||
|
fn rgba_raw_to_png_checked(rgba: &[u8], width: i32, height: i32) -> Result<CaptureData, String> {
|
||||||
let png = rgba_raw_to_png(rgba, width, height)?;
|
let png = rgba_raw_to_png(rgba, width, height)?;
|
||||||
Ok(CaptureData {
|
Ok(CaptureData {
|
||||||
png_base64: base64_encode(&png),
|
png_base64: base64_encode(&png),
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
//! Tauri 命令:截图模块
|
//! Tauri 命令:截图模块
|
||||||
//!
|
//!
|
||||||
//! 命令清单:
|
//! 命令清单:
|
||||||
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码)
|
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码),返回捕获时刻光标坐标
|
||||||
//! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除)
|
//! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除)
|
||||||
//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用)
|
//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用)
|
||||||
//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时)
|
//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时)
|
||||||
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
|
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
|
||||||
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口
|
//! - screenshot_pick_list:枚举可拾取顶层窗口(Z 序,前端缓存后本地命中测试)
|
||||||
|
//! - screenshot_show_overlay:一次 IPC 完成覆盖层 show + focus(关键路径减少往返)
|
||||||
//! - screenshot_enum_windows:枚举可见顶层窗口
|
//! - screenshot_enum_windows:枚举可见顶层窗口
|
||||||
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
|
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
|
||||||
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
|
//! - screenshot_scroll_capture / screenshot_scroll_start / screenshot_scroll_finish /
|
||||||
|
//! screenshot_scroll_cancel:滚动截图(同步一次调用 / 会话式:启动、完成、取消)
|
||||||
|
//! - screenshot_set_scroll_hole:滚动模式遮罩挖孔(防 Chromium 遮挡检测冻结目标窗口)
|
||||||
|
//! - screenshot_take_editor_image_raw:取出编辑器图片(raw IPC,滚动截图会话直接写入)
|
||||||
|
//! - screenshot_compose_png / screenshot_compose_copy:raw RGBA → PNG(仅编码 / 剪贴板+编码)
|
||||||
//! - screenshot_copy_image:写入剪贴板(CF_DIB)
|
//! - screenshot_copy_image:写入剪贴板(CF_DIB)
|
||||||
//! - screenshot_save_png:写入文件
|
//! - screenshot_save_png:写入文件
|
||||||
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
|
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
|
||||||
|
|
||||||
use super::{CaptureData, WindowInfo};
|
use super::{CaptureData, CaptureStart, WindowInfo};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
/// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画
|
/// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -67,16 +72,40 @@ pub async fn screenshot_unregister_shortcut(app: tauri::AppHandle) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码
|
/// 注册(或切换)贴图全局快捷键。传入空字符串则禁用快捷键。
|
||||||
|
/// 按下时 emit 'screenshot-pin-shortcut',由前端切换贴图窗口显示/隐藏。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn screenshot_capture_fullscreen() -> Result<(), String> {
|
pub async fn screenshot_register_pin_shortcut(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
shortcut: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
crate::shortcut::register_shortcut(&app, "贴图", &shortcut, |a| {
|
||||||
|
let _ = a.emit(crate::constants::events::SCREENSHOT_PIN_SHORTCUT, ());
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注销贴图全局快捷键
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn screenshot_unregister_pin_shortcut(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
crate::shortcut::unregister_shortcut(&app, "贴图");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码。
|
||||||
|
/// 同时返回捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn screenshot_capture_fullscreen() -> Result<CaptureStart, String> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
|
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
|
||||||
tauri::async_runtime::spawn_blocking(|| {
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
let img = super::capture::capture_virtual_screen()?;
|
let img = super::capture::capture_virtual_screen()?;
|
||||||
super::capture::store_fullscreen(img)
|
super::capture::store_fullscreen(img)?;
|
||||||
|
let (cursor_x, cursor_y) = super::capture::cursor_pos().unwrap_or((0, 0));
|
||||||
|
Ok(CaptureStart { cursor_x, cursor_y })
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("捕获任务失败: {}", e))?
|
.map_err(|e| format!("捕获任务失败: {}", e))?
|
||||||
@@ -184,29 +213,39 @@ pub async fn screenshot_crop_copy_stored(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 拾取指定物理屏幕坐标下的顶层窗口
|
/// 枚举可拾取的顶层窗口(Z 序顶→底,排除本进程/不可见/工具窗口)。
|
||||||
|
/// 前端在截图开始时缓存列表,鼠标移动时在 JS 侧本地命中测试,消除逐帧 IPC 往返。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn screenshot_window_from_point(
|
pub async fn screenshot_pick_list() -> Result<Vec<WindowInfo>, String> {
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
) -> Result<Option<WindowInfo>, String> {
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(super::capture::pick_windows)
|
||||||
Ok(super::capture::window_from_point(x, y))
|
.await
|
||||||
})
|
.map_err(|e| format!("枚举失败: {}", e))
|
||||||
.await
|
|
||||||
.map_err(|e| format!("查询失败: {}", e))?
|
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
let _ = (x, y);
|
Ok(vec![])
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
|
/// 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn screenshot_show_overlay(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
label: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let win = app
|
||||||
|
.get_webview_window(&label)
|
||||||
|
.ok_or_else(|| format!("窗口不存在: {}", label))?;
|
||||||
|
win.show().map_err(|e| format!("显示窗口失败: {}", e))?;
|
||||||
|
win.set_focus().map_err(|e| format!("聚焦窗口失败: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前鼠标物理屏幕坐标(贴图窗口拖动跟随等场景使用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
|
pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
|
||||||
@@ -262,19 +301,138 @@ pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 存入编辑器图片(base64 PNG)
|
/// 滚动截图:从窗口当前滚动位置向下拼接到底部,返回超长 PNG。
|
||||||
|
/// `region` 为 Some 时仅在框选区域(屏幕物理坐标)内捕捉,宽 = 选区宽;
|
||||||
|
/// 为 None 时捕捉整个客户区。结束后会把窗口滚回起始位置,不打扰用户。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), String> {
|
pub async fn screenshot_scroll_capture(
|
||||||
super::set_editor_image(png_base64);
|
hwnd: isize,
|
||||||
Ok(())
|
region: Option<super::ScrollRegion>,
|
||||||
|
) -> Result<CaptureData, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
super::scroll_capture::scroll_capture(hwnd, region)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("滚动截图任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = hwnd;
|
||||||
|
let _ = region;
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除)
|
/// 启动滚动截图会话(后台线程持续捕捉拼接,实时推进度事件)。
|
||||||
|
/// `auto = true` 为自动滚动(线程主动下滚拼到底部);`false` 为手动(等用户滚动窗口)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn screenshot_get_editor_image() -> Result<Option<String>, String> {
|
pub fn screenshot_scroll_start(
|
||||||
Ok(super::take_editor_image())
|
app: AppHandle,
|
||||||
|
hwnd: isize,
|
||||||
|
region: Option<super::ScrollRegion>,
|
||||||
|
auto: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
super::scroll_session::start(app, hwnd, region, auto)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (app, hwnd, region, auto);
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结束滚动截图会话并导出结果。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn screenshot_scroll_finish() -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
super::scroll_session::finish()
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消滚动截图会话(不导出)。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn screenshot_scroll_cancel() -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
super::scroll_session::cancel_now()
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滚动模式遮罩挖孔:在截图覆盖层窗口上挖出选区带的真孔(region = None 时复位整窗)。
|
||||||
|
///
|
||||||
|
/// Chromium 系浏览器(Edge/Chrome)的窗口遮挡检测会把被完全覆盖的窗口标记为
|
||||||
|
/// occluded 并暂停渲染——滚动截图时覆盖层铺满全屏,网页"看起来完全不滚动"。
|
||||||
|
/// 挖孔后目标窗口仅部分被覆盖,恢复渲染与滚轮响应(详见 capture::set_scroll_hole)。
|
||||||
|
/// 进入滚动模式时带选区调用,会话结束/新一轮截图开始时必须传 None 复位。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn screenshot_set_scroll_hole(
|
||||||
|
app: AppHandle,
|
||||||
|
region: Option<super::ScrollRegion>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use raw_window_handle::HasWindowHandle;
|
||||||
|
|
||||||
|
let mut hwnds: Vec<isize> = Vec::new();
|
||||||
|
for (label, win) in app.webview_windows() {
|
||||||
|
if label.starts_with(crate::constants::windows::SCREENSHOT_OVERLAY) {
|
||||||
|
let hwnd = win
|
||||||
|
.window_handle()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| match h.as_raw() {
|
||||||
|
raw_window_handle::RawWindowHandle::Win32(w) => {
|
||||||
|
Some(w.hwnd.get() as isize)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
if let Some(h) = hwnd {
|
||||||
|
hwnds.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hwnds.is_empty() {
|
||||||
|
return Err("截图覆盖层窗口不存在".into());
|
||||||
|
}
|
||||||
|
for h in hwnds {
|
||||||
|
super::capture::set_scroll_hole(h, region)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (app, region);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取出编辑器图片(原始 PNG 字节,raw IPC → 前端 ArrayBuffer → Blob URL,取出即清除)
|
||||||
|
///
|
||||||
|
/// 长图(滚动截图)可达数十 MB:raw IPC 相比 base64 JSON 事件传输省 ~33% 体积,
|
||||||
|
/// 且避免 JSON 序列化/多次广播。注:返回 ipc::Response,specta 无法生成,豁免标注。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_take_editor_image_raw() -> Result<tauri::ipc::Response, String> {
|
||||||
|
match super::take_editor_image_raw() {
|
||||||
|
Some(bytes) => Ok(tauri::ipc::Response::new(bytes)),
|
||||||
|
None => Err("无待编辑的截图".into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将 PNG base64 写入系统剪贴板(转 CF_DIB)
|
/// 将 PNG base64 写入系统剪贴板(转 CF_DIB)
|
||||||
@@ -304,6 +462,39 @@ pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn screenshot_compose_copy(
|
pub async fn screenshot_compose_copy(
|
||||||
request: tauri::ipc::Request<'_>,
|
request: tauri::ipc::Request<'_>,
|
||||||
|
) -> Result<super::CaptureData, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let body = match request.body() {
|
||||||
|
tauri::ipc::InvokeBody::Raw(data) => data.clone(),
|
||||||
|
_ => return Err("需要 raw body(ArrayBuffer)".into()),
|
||||||
|
};
|
||||||
|
if body.len() < 8 {
|
||||||
|
return Err("数据不足:缺少尺寸头".into());
|
||||||
|
}
|
||||||
|
let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]);
|
||||||
|
let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]);
|
||||||
|
let rgba = body[8..].to_vec();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
super::capture::compose_copy_rgba(&rgba, width, height)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("合成复制任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = request;
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有标注导出(仅编码):接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 PNG 编码。
|
||||||
|
/// 与 screenshot_compose_copy 的区别:不写剪贴板(编辑器「保存到文件」用)。
|
||||||
|
/// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。
|
||||||
|
/// 注:参数为 tauri::ipc::Request(原始 body),specta 无法生成,豁免标注。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_compose_png(
|
||||||
|
request: tauri::ipc::Request<'_>,
|
||||||
) -> Result<super::CaptureData, String> {
|
) -> Result<super::CaptureData, String> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -318,10 +509,10 @@ pub async fn screenshot_compose_copy(
|
|||||||
let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]);
|
let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]);
|
||||||
let rgba = body[8..].to_vec();
|
let rgba = body[8..].to_vec();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
super::capture::compose_copy_rgba(&rgba, width, height)
|
super::capture::compose_png_rgba(&rgba, width, height)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("合成复制任务失败: {}", e))?
|
.map_err(|e| format!("合成编码任务失败: {}", e))?
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
@@ -349,17 +540,17 @@ pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(),
|
|||||||
.map_err(|e| format!("保存任务失败: {}", e))?
|
.map_err(|e| format!("保存任务失败: {}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 截图历史缓存:完整 PNG 落盘缓存目录,内存只保留缩略图 =====
|
// ===== 截图历史缓存:完整 PNG 落盘到应用数据目录(持久化,随历史保留数量清理),内存只保留缩略图 =====
|
||||||
|
|
||||||
/// 历史缓存根目录(app_cache_dir/screenshot/history)
|
/// 历史根目录(app_data_dir/screenshot/history)
|
||||||
fn history_cache_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
fn history_cache_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_cache_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| format!("获取缓存目录失败: {}", e))?
|
.map_err(|e| format!("获取应用数据目录失败: {}", e))?
|
||||||
.join("screenshot")
|
.join("screenshot")
|
||||||
.join("history");
|
.join("history");
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建缓存目录失败: {}", e))?;
|
std::fs::create_dir_all(&dir).map_err(|e| format!("创建历史目录失败: {}", e))?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +603,22 @@ pub async fn screenshot_load_cache(
|
|||||||
.map_err(|e| format!("读取缓存任务失败: {}", e))?
|
.map_err(|e| format!("读取缓存任务失败: {}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取历史缓存 PNG 原始字节(raw IPC → 前端 ArrayBuffer,贴图窗口显示用:
|
||||||
|
/// 跳过 base64 编码,IPC 传输与前端内存占用均省 ~33%;同 get_fullscreen_bmp 豁免 specta)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_load_cache_raw(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
path: String,
|
||||||
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let p = ensure_in_history_dir(&app, &path)?;
|
||||||
|
let bytes = std::fs::read(&p).map_err(|e| format!("读取缓存失败: {}", e))?;
|
||||||
|
Ok(tauri::ipc::Response::new(bytes))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("读取缓存任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件)
|
/// 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ use specta::Type;
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod wgc_capture;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod scroll_capture;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod scroll_session;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
/// 前端可见的捕获数据
|
/// 前端可见的捕获数据
|
||||||
@@ -20,6 +26,14 @@ pub struct CaptureData {
|
|||||||
pub height: i32,
|
pub height: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 截图启动信息:捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)
|
||||||
|
#[derive(serde::Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CaptureStart {
|
||||||
|
pub cursor_x: i32,
|
||||||
|
pub cursor_y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// 窗口信息(窗口拾取 / 枚举)
|
/// 窗口信息(窗口拾取 / 枚举)
|
||||||
#[derive(serde::Serialize, Type)]
|
#[derive(serde::Serialize, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -40,17 +54,28 @@ pub struct ScreenRect {
|
|||||||
pub height: i32,
|
pub height: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出)
|
/// 滚动截图区域(屏幕物理像素坐标,通常为覆盖层框选区平移到屏幕)
|
||||||
static EDITOR_IMAGE: Mutex<Option<String>> = Mutex::new(None);
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScrollRegion {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// 存储编辑器图片(base64 PNG)
|
/// 编辑器图片静态存储:原始 PNG 字节(滚动截图会话完成后直接写入,
|
||||||
pub fn set_editor_image(png_base64: String) {
|
/// 编辑器窗口通过 raw IPC 取出 → Blob URL 显示,全程不经 base64/JSON 事件传输)
|
||||||
if let Ok(mut g) = EDITOR_IMAGE.lock() {
|
static EDITOR_IMAGE_RAW: Mutex<Option<Vec<u8>>> = Mutex::new(None);
|
||||||
*g = Some(png_base64);
|
|
||||||
|
/// 存储编辑器图片(原始 PNG 字节)
|
||||||
|
pub fn set_editor_image_raw(png: Vec<u8>) {
|
||||||
|
if let Ok(mut g) = EDITOR_IMAGE_RAW.lock() {
|
||||||
|
*g = Some(png);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取出并清除编辑器图片
|
/// 取出并清除编辑器图片(原始 PNG 字节)
|
||||||
pub fn take_editor_image() -> Option<String> {
|
pub fn take_editor_image_raw() -> Option<Vec<u8>> {
|
||||||
EDITOR_IMAGE.lock().ok()?.take()
|
EDITOR_IMAGE_RAW.lock().ok()?.take()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! 滚动截图(垂直长图拼接)底层设施(仅 Windows)
|
||||||
|
//!
|
||||||
|
//! ## 原理
|
||||||
|
//! 目标窗口的**客户区**内容往往是可分页垂直滚动的。算法:
|
||||||
|
//! 1. 用 PrintWindow(PW_RENDERFULLCONTENT)抓客户区为 BGRA(不做 PNG 编码);
|
||||||
|
//! 2. 向窗口发送 `WM_MOUSEWHEEL` 使其向下滚动;
|
||||||
|
//! 3. 再抓一帧,用**框选带内多列采样行信号**做一维模板匹配,求出两帧间的垂直
|
||||||
|
//! 滚动像素偏移 `d`:`cur[y] ≈ prev[y+d]`,故把 `cur` 底部新出现的 `d` 行拼到画布末尾;
|
||||||
|
//! 4. 重复直到内容不再变化(滚到底部),把各帧拼成一张超高纵轴图像。
|
||||||
|
//!
|
||||||
|
//! 固定表头/粘性头部由拼接语义天然处理——只追加 `cur` 底部的 `d` 行,
|
||||||
|
//! 表头保留在第一帧中,不会重复。
|
||||||
|
//!
|
||||||
|
//! ## 滚轮投递
|
||||||
|
//! `WM_MOUSEWHEEL` 优先发给**框选带中心处的最深子窗口**(如 Chromium 的
|
||||||
|
//! RenderWidgetHostHWND):很多程序的顶层窗口过程不转发滚轮消息,
|
||||||
|
//! 直接发顶层会导致"完全不滚动"。坐标一律用屏幕物理坐标(lParam 语义)。
|
||||||
|
|
||||||
|
use std::thread::sleep;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{HWND, POINT, RECT};
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{ClientToScreen, ScreenToClient};
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
ChildWindowFromPointEx, GetClientRect, GetWindowRect, PostMessageW, SendMessageTimeoutW,
|
||||||
|
WM_MOUSEWHEEL, WHEEL_DELTA, CWP_SKIPDISABLED, CWP_SKIPINVISIBLE, CWP_SKIPTRANSPARENT,
|
||||||
|
SMTO_ABORTIFHUNG,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{CaptureData, ScrollRegion};
|
||||||
|
use super::capture::{base64_encode, bgra_to_png, capture_window_bgra, crop_bgra};
|
||||||
|
|
||||||
|
/// 窗口客户区上下文:客户区尺寸 + 客户区在窗口位图内的偏移。
|
||||||
|
pub(crate) struct BandCtx {
|
||||||
|
pub cw: i32,
|
||||||
|
pub ch: i32,
|
||||||
|
pub ox: i32,
|
||||||
|
pub oy: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析框选带:把可选区域(屏幕物理坐标)换算成客户区内的裁剪带。
|
||||||
|
/// 区域未命中客户区时返回错误;`None` 表示整客户区。
|
||||||
|
/// 返回 (ctx, band_x, band_y, band_w, band_h),band_* 为客户区内坐标。
|
||||||
|
pub(crate) fn resolve_band(
|
||||||
|
hwnd: isize,
|
||||||
|
region: Option<ScrollRegion>,
|
||||||
|
) -> Result<(BandCtx, i32, i32, i32, i32), String> {
|
||||||
|
let (cw, ch, ox, oy) = client_info(hwnd)
|
||||||
|
.ok_or_else(|| "无法获取窗口客户区(窗口可能被最小化或已销毁)".to_string())?;
|
||||||
|
if cw <= 0 || ch <= 0 {
|
||||||
|
return Err("窗口客户区尺寸无效".into());
|
||||||
|
}
|
||||||
|
let (wr_x, wr_y) = window_rect_origin(hwnd).ok_or_else(|| "无法获取窗口矩形".to_string())?;
|
||||||
|
let band = match region {
|
||||||
|
Some(r) => {
|
||||||
|
let wbx = r.x - wr_x;
|
||||||
|
let wby = r.y - wr_y;
|
||||||
|
let bx0 = wbx.max(ox);
|
||||||
|
let by0 = wby.max(oy);
|
||||||
|
let bx1 = (wbx + r.width).min(ox + cw);
|
||||||
|
let by1 = (wby + r.height).min(oy + ch);
|
||||||
|
if bx1 <= bx0 || by1 <= by0 {
|
||||||
|
return Err("选区未命中窗口客户区".into());
|
||||||
|
}
|
||||||
|
(bx0 - ox, by0 - oy, bx1 - bx0, by1 - by0)
|
||||||
|
}
|
||||||
|
None => (0, 0, cw, ch),
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
BandCtx { cw, ch, ox, oy },
|
||||||
|
band.0,
|
||||||
|
band.1,
|
||||||
|
band.2,
|
||||||
|
band.3,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户区内坐标 → 屏幕物理坐标。
|
||||||
|
pub(crate) fn client_pt_to_screen(hwnd: isize, cx: i32, cy: i32) -> Option<(i32, i32)> {
|
||||||
|
unsafe {
|
||||||
|
let mut pt = POINT { x: cx, y: cy };
|
||||||
|
if ClientToScreen(hwnd as HWND, &mut pt) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((pt.x, pt.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归下钻:找到客户区坐标 (cx, cy) 处最深的子窗口(跳过不可见/禁用/透明子窗口)。
|
||||||
|
/// WM_MOUSEWHEEL 优先发给真正处理滚轮的子窗口——顶层窗口过程往往不转发滚轮,
|
||||||
|
/// 这是"自动滚动一开始就不动"的主因之一。
|
||||||
|
pub(crate) fn deep_child_at_point(top: isize, mut cx: i32, mut cy: i32) -> isize {
|
||||||
|
let mut cur = top;
|
||||||
|
for _ in 0..16 {
|
||||||
|
let child = unsafe {
|
||||||
|
ChildWindowFromPointEx(
|
||||||
|
cur as HWND,
|
||||||
|
POINT { x: cx, y: cy },
|
||||||
|
CWP_SKIPINVISIBLE | CWP_SKIPDISABLED | CWP_SKIPTRANSPARENT,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if child == 0 || child == cur as HWND {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 坐标换算到子窗口客户区
|
||||||
|
let mut pt = POINT { x: cx, y: cy };
|
||||||
|
unsafe {
|
||||||
|
if ClientToScreen(cur as HWND, &mut pt) == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ScreenToClient(child, &mut pt) == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx = pt.x;
|
||||||
|
cy = pt.y;
|
||||||
|
cur = child as isize;
|
||||||
|
}
|
||||||
|
cur
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造 WM_MOUSEWHEEL 的 wParam/lParam(lParam 为屏幕坐标)。
|
||||||
|
fn wheel_params(sx: i32, sy: i32, delta: i32) -> (usize, isize) {
|
||||||
|
// wParam 高位字 = 有符号 delta,低位字 = 按键 0
|
||||||
|
let wparam = ((delta as u16) as usize) << 16;
|
||||||
|
// lParam 低 16 位 = x(屏幕),高 16 位 = y(屏幕)
|
||||||
|
let lparam = ((((sy as u32) & 0xFFFF) << 16) | ((sx as u32) & 0xFFFF)) as isize;
|
||||||
|
(wparam, lparam)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送滚轮消息(SendMessageTimeout:目标线程短暂忙/挂起时不至于卡死调用线程)。
|
||||||
|
/// delta > 0 向上滚,delta < 0 向下滚。
|
||||||
|
pub(crate) fn send_wheel(hwnd: isize, sx: i32, sy: i32, delta: i32) {
|
||||||
|
let (wparam, lparam) = wheel_params(sx, sy, delta);
|
||||||
|
let mut result = 0usize;
|
||||||
|
unsafe {
|
||||||
|
let _ = SendMessageTimeoutW(
|
||||||
|
hwnd as HWND,
|
||||||
|
WM_MOUSEWHEEL,
|
||||||
|
wparam,
|
||||||
|
lparam,
|
||||||
|
SMTO_ABORTIFHUNG,
|
||||||
|
80,
|
||||||
|
&mut result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异步投递滚轮消息(部分程序只处理经消息泵排队的事件)。
|
||||||
|
pub(crate) fn post_wheel(hwnd: isize, sx: i32, sy: i32, delta: i32) {
|
||||||
|
let (wparam, lparam) = wheel_params(sx, sy, delta);
|
||||||
|
unsafe {
|
||||||
|
let _ = PostMessageW(hwnd as HWND, WM_MOUSEWHEEL, wparam, lparam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 单次同步滚动截图(一次调用拼到底)=====
|
||||||
|
|
||||||
|
/// 每次向下滚动的「格数」(WHEEL_DELTA=120/格)。
|
||||||
|
const WHEEL_STROKE: i32 = 2;
|
||||||
|
/// 滚动后等待窗口重绘的时间。
|
||||||
|
const SETTLE_MS: u64 = 70;
|
||||||
|
/// 保护上限:最大迭代轮数。
|
||||||
|
const MAX_ITERS: usize = 140;
|
||||||
|
/// 保护上限:最大拼接段数。
|
||||||
|
const MAX_STITCHES: usize = 90;
|
||||||
|
/// 保护上限:拼接后总高(像素)。
|
||||||
|
const MAX_TOTAL_H: u32 = 30000;
|
||||||
|
/// 连续多少次滚动无位移判定为「已到底部」。
|
||||||
|
const NO_CHANGE_STOP: u32 = 2;
|
||||||
|
|
||||||
|
/// 执行滚动截图:把窗口当前滚动位置向下拼接到底部,返回超长 PNG(同步,一次调用)。
|
||||||
|
///
|
||||||
|
/// - `region` 为 `Some` 时,只在**框选区域**内捕捉(列带 + 该区域的纵向视口),
|
||||||
|
/// 输出宽度 = 选区宽度;为 `None` 时捕捉整个客户区。
|
||||||
|
/// 偏移检测始终优先使用带内信号,保证滚动量在窄带下也能稳健匹配。
|
||||||
|
/// - 结束时把窗口滚回起始位置,不打扰用户。
|
||||||
|
pub fn scroll_capture(hwnd: isize, region: Option<ScrollRegion>) -> Result<CaptureData, String> {
|
||||||
|
let (ctx, band_x, band_y, band_w, band_h) = resolve_band(hwnd, region)?;
|
||||||
|
let (cw, ch) = (ctx.cw, ctx.ch);
|
||||||
|
let (client_ox, client_oy) = (ctx.ox, ctx.oy);
|
||||||
|
|
||||||
|
// 滚轮目标:带中心的最深子窗口 + 屏幕坐标
|
||||||
|
let bcx = band_x + band_w / 2;
|
||||||
|
let bcy = band_y + band_h / 2;
|
||||||
|
let wheel_hwnd = deep_child_at_point(hwnd, bcx, bcy);
|
||||||
|
let (sx, sy) = client_pt_to_screen(hwnd, bcx, bcy)
|
||||||
|
.ok_or_else(|| "无法换算屏幕坐标".to_string())?;
|
||||||
|
|
||||||
|
// 第一帧(全客户区)→ 画布(裁剪到框选带)
|
||||||
|
let first = capture_client_bgra(hwnd, client_ox, client_oy, cw, ch)?;
|
||||||
|
let mut canvas = crop_bgra(&first, cw as usize, band_x, band_y, band_w, band_h)?;
|
||||||
|
let mut prev = first;
|
||||||
|
let mut total_h = band_h as u32;
|
||||||
|
let mut total_stitches = 0usize;
|
||||||
|
let mut no_change = 0u32;
|
||||||
|
let mut moved: u32 = 0;
|
||||||
|
let mut iters = 0usize;
|
||||||
|
|
||||||
|
while iters < MAX_ITERS && total_stitches < MAX_STITCHES && total_h < MAX_TOTAL_H {
|
||||||
|
iters += 1;
|
||||||
|
|
||||||
|
send_wheel(wheel_hwnd, sx, sy, -(WHEEL_DELTA as i32) * WHEEL_STROKE);
|
||||||
|
sleep(Duration::from_millis(SETTLE_MS));
|
||||||
|
|
||||||
|
let cur = match capture_client_bgra(hwnd, client_ox, client_oy, cw, ch) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => break, // 窗口中途被关闭/失去客户区
|
||||||
|
};
|
||||||
|
|
||||||
|
match detect_vscroll_offset(
|
||||||
|
&prev,
|
||||||
|
&cur,
|
||||||
|
cw as usize,
|
||||||
|
ch as usize,
|
||||||
|
band_x as usize,
|
||||||
|
band_y as usize,
|
||||||
|
band_w as usize,
|
||||||
|
band_h as usize,
|
||||||
|
) {
|
||||||
|
Some(0) => {
|
||||||
|
no_change += 1;
|
||||||
|
if no_change >= NO_CHANGE_STOP {
|
||||||
|
break; // 已到底部
|
||||||
|
}
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
Some(d) => {
|
||||||
|
no_change = 0;
|
||||||
|
total_stitches += 1;
|
||||||
|
moved += 1;
|
||||||
|
// 追加框选带底部新出现的 d 行(在客户区帧内的带区间 [band_y, band_y+band_h))
|
||||||
|
let new_rows = (d as i32).min(band_h) as usize;
|
||||||
|
let new_y = band_y + band_h - new_rows as i32;
|
||||||
|
let rows = crop_bgra(&cur, cw as usize, band_x, new_y, band_w, new_rows as i32)?;
|
||||||
|
canvas.extend_from_slice(&rows);
|
||||||
|
total_h += new_rows as u32;
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// 匹配失败(内容大幅变化/动画等):不拼接、不累计 no_change,下一轮继续
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束回滚:上滚与下滚相同数量的手势
|
||||||
|
for _ in 0..moved {
|
||||||
|
send_wheel(wheel_hwnd, sx, sy, WHEEL_DELTA as i32 * WHEEL_STROKE);
|
||||||
|
sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_h <= band_h as u32 {
|
||||||
|
return Err("区域内容未能滚动(可能不支持鼠标滚轮或已到底部)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let png = bgra_to_png(&canvas, band_w as i32, total_h as i32)?;
|
||||||
|
Ok(CaptureData {
|
||||||
|
png_base64: base64_encode(&png),
|
||||||
|
width: band_w as i32,
|
||||||
|
height: total_h as i32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 窗口矩形左上角的屏幕坐标(用于把屏幕区域换算成窗口位图内的裁剪坐标)。
|
||||||
|
fn window_rect_origin(hwnd: isize) -> Option<(i32, i32)> {
|
||||||
|
unsafe {
|
||||||
|
let mut wr: RECT = std::mem::zeroed();
|
||||||
|
if GetWindowRect(hwnd as HWND, &mut wr) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((wr.left, wr.top))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取窗口客户区尺寸与客户区左上角屏幕坐标。
|
||||||
|
fn client_info(hwnd: isize) -> Option<(i32, i32, i32, i32)> {
|
||||||
|
unsafe {
|
||||||
|
let h = hwnd as HWND;
|
||||||
|
let mut cr: RECT = std::mem::zeroed();
|
||||||
|
if GetClientRect(h, &mut cr) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (cw, ch) = (cr.right - cr.left, cr.bottom - cr.top);
|
||||||
|
let mut pt: POINT = std::mem::zeroed();
|
||||||
|
if ClientToScreen(h, &mut pt) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut wr: RECT = std::mem::zeroed();
|
||||||
|
if GetWindowRect(h, &mut wr) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((cw, ch, pt.x - wr.left, pt.y - wr.top))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 抓取窗口客户区内容(PrintWindow + 裁剪,不做 PNG 编码)。
|
||||||
|
pub(crate) fn capture_client_bgra(
|
||||||
|
hwnd: isize,
|
||||||
|
client_ox: i32,
|
||||||
|
client_oy: i32,
|
||||||
|
cw: i32,
|
||||||
|
ch: i32,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let img = capture_window_bgra(hwnd)?;
|
||||||
|
// 客户区在窗口位图内的偏移(PrintWindow 从窗口左上角绘制)
|
||||||
|
crop_bgra(
|
||||||
|
&img.bgra,
|
||||||
|
img.width as usize,
|
||||||
|
client_ox,
|
||||||
|
client_oy,
|
||||||
|
cw.min(img.width),
|
||||||
|
ch.min(img.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 帧间垂直偏移检测 =====
|
||||||
|
|
||||||
|
/// 行信号采样列数:每行采 16 列亮度(B+G+R),保留横向细节(文字边缘、分隔线)。
|
||||||
|
/// 相比旧的"整行平均",多列采样大幅降低重复纹理/大面积纯色区的误匹配率。
|
||||||
|
const SIG_COLS: usize = 16;
|
||||||
|
/// 单周期可检测的最大偏移(像素)。超过即匹配失败(调用方自适应降速)。
|
||||||
|
const MAX_OFFSET: usize = 512;
|
||||||
|
|
||||||
|
/// 检测两帧之间的垂直滚动像素偏移 `d`,使 `cur[y] ≈ prev[y+d]`。
|
||||||
|
///
|
||||||
|
/// 匹配范围优先限定在**框选带**(行=带内行、列=带内列):带外内容(工具栏、
|
||||||
|
/// 状态栏、不随滚动的区域)不参与匹配,避免污染信号。带太小/带内匹配失败时
|
||||||
|
/// 逐级回退(全行+带列 → 全行+全列)。
|
||||||
|
///
|
||||||
|
/// 返回:
|
||||||
|
/// - `Some(0)`:两帧实质相同(未滚动 / 已到底部)
|
||||||
|
/// - `Some(d)` d>0:检测到向下滚动了 d 像素
|
||||||
|
/// - `None`:无法可靠匹配(内容动画 / 位移超上限等),调用方跳过本轮
|
||||||
|
pub(crate) fn detect_vscroll_offset(
|
||||||
|
prev: &[u8],
|
||||||
|
cur: &[u8],
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
band_l: usize,
|
||||||
|
band_t: usize,
|
||||||
|
band_w: usize,
|
||||||
|
band_h: usize,
|
||||||
|
) -> Option<usize> {
|
||||||
|
if w < 4 || h < 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let band_ok = band_w >= 8 && band_h >= 24 && band_t + band_h <= h && band_l + band_w <= w;
|
||||||
|
if band_ok {
|
||||||
|
// 主匹配:行、列都限定在带内(带外内容不随滚动变化,会污染匹配信号)
|
||||||
|
let ps = row_signals(prev, w, band_t, band_h, band_l, band_w);
|
||||||
|
let cs = row_signals(cur, w, band_t, band_h, band_l, band_w);
|
||||||
|
if let Some(r) = match_signals(&ps, &cs) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
// 回退(带内匹配失败:快速滚动位移超上限 / 带内大面积动画):
|
||||||
|
// 全客户区行只用于**找位移**,不判"无变化"——带外静止内容会把误差拉低,
|
||||||
|
// 误报 Some(0) 造成假"到底"
|
||||||
|
if band_l + band_w <= w {
|
||||||
|
let ps = row_signals(prev, w, 0, h, band_l, band_w);
|
||||||
|
let cs = row_signals(cur, w, 0, h, band_l, band_w);
|
||||||
|
if let Some(d) = match_signals(&ps, &cs).filter(|d| *d > 0) {
|
||||||
|
return Some(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ps = row_signals(prev, w, 0, h, 0, w);
|
||||||
|
let cs = row_signals(cur, w, 0, h, 0, w);
|
||||||
|
return match_signals(&ps, &cs).filter(|d| *d > 0);
|
||||||
|
}
|
||||||
|
// 带太小:全客户区回退(此时允许 Some(0),否则永远判不了"到底")
|
||||||
|
if band_w >= 8 && band_l + band_w <= w {
|
||||||
|
let ps = row_signals(prev, w, 0, h, band_l, band_w);
|
||||||
|
let cs = row_signals(cur, w, 0, h, band_l, band_w);
|
||||||
|
if let Some(r) = match_signals(&ps, &cs) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ps = row_signals(prev, w, 0, h, 0, w);
|
||||||
|
let cs = row_signals(cur, w, 0, h, 0, w);
|
||||||
|
match_signals(&ps, &cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每行采样 SIG_COLS 列的亮度(B+G+R,0..765),返回 rows × SIG_COLS 的信号矩阵。
|
||||||
|
fn row_signals(bgra: &[u8], w: usize, y0: usize, rows: usize, l: usize, bw: usize) -> Vec<i32> {
|
||||||
|
// 均匀采样列(含两端)
|
||||||
|
let mut cols = [0usize; SIG_COLS];
|
||||||
|
for i in 0..SIG_COLS {
|
||||||
|
cols[i] = l + (i * (bw - 1)) / (SIG_COLS - 1).max(1);
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(rows * SIG_COLS);
|
||||||
|
for y in y0..y0 + rows {
|
||||||
|
let base = y * w * 4;
|
||||||
|
for &c in &cols {
|
||||||
|
let i = base + c * 4;
|
||||||
|
// bgra: B,G,R(alpha 通常为 0,不计入亮度)
|
||||||
|
out.push(bgra[i] as i32 + bgra[i + 1] as i32 + bgra[i + 2] as i32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 信号模板匹配:求 d 使 `cs[y] ≈ ps[y+d]`,取平均绝对误差最小者。
|
||||||
|
fn match_signals(ps: &[i32], cs: &[i32]) -> Option<usize> {
|
||||||
|
let n = ps.len() / SIG_COLS;
|
||||||
|
if n < 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let step = 2; // 隔行采样加速
|
||||||
|
let max_d = MAX_OFFSET.min(n * 3 / 4);
|
||||||
|
if max_d < 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// d=0 基准误差:两帧几乎一致 → 未滚动(到底部)
|
||||||
|
let err0 = {
|
||||||
|
let (mut e, mut c) = (0i64, 0i64);
|
||||||
|
let mut y = 0;
|
||||||
|
while y < n {
|
||||||
|
for k in 0..SIG_COLS {
|
||||||
|
e += (cs[y * SIG_COLS + k] - ps[y * SIG_COLS + k]).abs() as i64;
|
||||||
|
}
|
||||||
|
c += SIG_COLS as i64;
|
||||||
|
y += step;
|
||||||
|
}
|
||||||
|
if c == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
e as f64 / c as f64
|
||||||
|
};
|
||||||
|
if err0 < 4.0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找最小平均误差的 d
|
||||||
|
let mut best_d = 0usize;
|
||||||
|
let mut best_err = f64::MAX;
|
||||||
|
for d in 1..=max_d {
|
||||||
|
let (mut e, mut c) = (0i64, 0i64);
|
||||||
|
let mut y = 0;
|
||||||
|
while y + d < n {
|
||||||
|
for k in 0..SIG_COLS {
|
||||||
|
e += (cs[y * SIG_COLS + k] - ps[(y + d) * SIG_COLS + k]).abs() as i64;
|
||||||
|
}
|
||||||
|
c += SIG_COLS as i64;
|
||||||
|
y += step;
|
||||||
|
// 早停:明显劣于当前最优则放弃该候选
|
||||||
|
if c >= 64 && e as f64 / c as f64 > best_err * 1.8 + 24.0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c >= 64 {
|
||||||
|
let ae = e as f64 / c as f64;
|
||||||
|
if ae < best_err {
|
||||||
|
best_err = ae;
|
||||||
|
best_d = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_d == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// 阈值按 3 通道和(0..765)标定:亚像素滚动会有轻微重采样模糊,阈值不宜过紧
|
||||||
|
if best_err < 30.0 && best_err * 2.0 < err0 {
|
||||||
|
Some(best_d)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
//! 滚动截图会话(仅 Windows):后台线程捕捉拼接 + 独立滚轮线程平滑滚动。
|
||||||
|
//!
|
||||||
|
//! 与 [super::scroll_capture::scroll_capture](同步一次调用)不同,本模块把捕获循环
|
||||||
|
//! 放进后台线程,通过事件把**实时进度**推给前端。
|
||||||
|
//!
|
||||||
|
//! ## 自动模式架构
|
||||||
|
//! - **run_loop(会话线程)**:按节奏捕获客户区帧,与上一帧做带内偏移匹配,拼接新行;
|
||||||
|
//! - **ticker(滚轮线程)**:以 ~16ms 间隔发送小步长 `WM_MOUSEWHEEL`,由目标程序自身的
|
||||||
|
//! 平滑滚动动画呈现连续滚动(替代旧版"每 70ms 一整格"的跳变式滚动);
|
||||||
|
//! - **目标策略升级**:带中心最深子窗口(小步长 Send)→ 子窗口整格 → 顶层整格 Send →
|
||||||
|
//! 顶层整格 Post。解决"一开始就不滚动"(顶层不转发滚轮 / 程序忽略非整格消息);
|
||||||
|
//! - **自适应速度**:按每周期实测位移调滚轮步长 / 捕获周期,逼近"带高 1/4"的理想重叠;
|
||||||
|
//! - **停止补帧(settle)**:用户停止后等目标窗口滚动动画静止再补拼最后一帧,
|
||||||
|
//! 保证最终图片结尾 = 用户停止时窗口实际停留的位置;
|
||||||
|
//! - **精准回滚**:先按累计像素粗估上滚,再用帧匹配对初始帧校准回到起始位置。
|
||||||
|
//!
|
||||||
|
//! 事件(广播到所有窗口):
|
||||||
|
//! - `SCROLL_PROGRESS`:`{ width, height, auto }` 当前已拼接高度
|
||||||
|
//! - `SCROLL_COMPLETE`:`{ width, height }` 完成导出(PNG 原始字节已存入编辑器图片槽,
|
||||||
|
//! 由前端常驻编辑器窗口通过 raw IPC 取出,事件本身不携带图片数据)
|
||||||
|
//! - `SCROLL_CANCELLED`:`{}` 取消(丢弃画布)
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::thread::sleep;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::WHEEL_DELTA;
|
||||||
|
|
||||||
|
use crate::constants::events as evts;
|
||||||
|
use crate::logger::{log_error, log_info};
|
||||||
|
use super::capture::{bgra_to_png, crop_bgra};
|
||||||
|
use super::scroll_capture::{
|
||||||
|
capture_client_bgra, client_pt_to_screen, deep_child_at_point, detect_vscroll_offset,
|
||||||
|
post_wheel, resolve_band, send_wheel,
|
||||||
|
};
|
||||||
|
use super::ScrollRegion;
|
||||||
|
|
||||||
|
// ===== 可调参数 =====
|
||||||
|
|
||||||
|
/// 滚轮线程发送间隔(毫秒)。小步长 + 高频 → 目标程序的平滑滚动动画连贯不断帧。
|
||||||
|
const TICK_MS: u64 = 16;
|
||||||
|
/// 捕获周期基准(毫秒)。与滚轮解耦:滚动不因捕获/匹配而停顿。
|
||||||
|
const CYCLE_MS: u64 = 130;
|
||||||
|
/// 捕获周期上限(整格模式下按实测位移自适应放大)。
|
||||||
|
const CYCLE_MS_MAX: u64 = 360;
|
||||||
|
/// 每周期理想位移 = clamp(band_h / 4, TARGET_D_MIN, TARGET_D_MAX)。
|
||||||
|
/// 太小 → 慢;太大 → 带内重叠不足、匹配易失败。
|
||||||
|
const TARGET_D_MIN: usize = 32;
|
||||||
|
const TARGET_D_MAX: usize = 180;
|
||||||
|
/// 小步长模式:起始 / 上下限步长(WHEEL_DELTA=120 为一整格)。
|
||||||
|
const DELTA_START: i32 = 20;
|
||||||
|
const DELTA_MIN: i32 = 4;
|
||||||
|
const DELTA_MAX: i32 = 72;
|
||||||
|
/// 整格模式步长(部分程序忽略非整格滚轮消息,累不进小步长)。
|
||||||
|
const DELTA_NOTCH: i32 = 120;
|
||||||
|
/// 策略未锁定时:连续多少周期无位移 → 升级投递策略。
|
||||||
|
const ESCALATE_CYCLES: u32 = 3;
|
||||||
|
/// 策略已锁定时:连续多少周期无位移 → 判定到底。
|
||||||
|
const NO_CHANGE_STOP: u32 = 3;
|
||||||
|
/// 上限:拼接后总高(像素),超出即自动结束。
|
||||||
|
const MAX_TOTAL_H: u32 = 30000;
|
||||||
|
/// 进度事件节流。
|
||||||
|
const PROGRESS_THROTTLE_MS: u64 = 120;
|
||||||
|
/// 停止后等待滚动动画静止的超时(毫秒)。
|
||||||
|
const SETTLE_TIMEOUT_MS: u64 = 900;
|
||||||
|
/// 回滚粗估:每整格对应的滚动像素。
|
||||||
|
const ROLLBACK_PX_PER_NOTCH: f64 = 55.0;
|
||||||
|
|
||||||
|
// ===== 会话状态 =====
|
||||||
|
|
||||||
|
/// 活动会话(会话线程独占读写;命令只改 STOP/CANCEL 原子标志)
|
||||||
|
struct Session {
|
||||||
|
hwnd: isize,
|
||||||
|
/// 客户区尺寸与客户区在窗口位图内偏移
|
||||||
|
cw: i32,
|
||||||
|
ch: i32,
|
||||||
|
ox: i32,
|
||||||
|
oy: i32,
|
||||||
|
band_x: i32,
|
||||||
|
band_y: i32,
|
||||||
|
band_w: i32,
|
||||||
|
band_h: i32,
|
||||||
|
/// 已拼接像素(带宽 × total_h)
|
||||||
|
canvas: Vec<u8>,
|
||||||
|
total_h: u32,
|
||||||
|
/// 累计检测到的滚动像素
|
||||||
|
ttl_px: u32,
|
||||||
|
/// 上一帧(全客户区,匹配基准)
|
||||||
|
prev: Vec<u8>,
|
||||||
|
/// 初始帧(回滚校准用)
|
||||||
|
first: Vec<u8>,
|
||||||
|
auto_scroll: bool,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
|
||||||
|
/// 请求线程结束(完成/取消共用)
|
||||||
|
static STOP: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// 结束方式:true = 取消(不导出),false = 完成(导出)
|
||||||
|
static CANCEL: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
// ===== 滚轮线程共享控制 =====
|
||||||
|
|
||||||
|
static TICKER_STOP: AtomicBool = AtomicBool::new(false);
|
||||||
|
static WHEEL_TARGET: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
/// 滚轮步长(有符号 delta,正值;发送时取负 = 向下滚)
|
||||||
|
static WHEEL_STEP: AtomicI32 = AtomicI32::new(0);
|
||||||
|
static WHEEL_POST: AtomicBool = AtomicBool::new(false);
|
||||||
|
static WHEEL_SX: AtomicI32 = AtomicI32::new(0);
|
||||||
|
static WHEEL_SY: AtomicI32 = AtomicI32::new(0);
|
||||||
|
|
||||||
|
/// 会话线程每轮用的只读参数(启动时一次性读出)。
|
||||||
|
struct Params {
|
||||||
|
hwnd: isize,
|
||||||
|
cw: i32,
|
||||||
|
ch: i32,
|
||||||
|
ox: i32,
|
||||||
|
oy: i32,
|
||||||
|
band_x: i32,
|
||||||
|
band_y: i32,
|
||||||
|
band_w: i32,
|
||||||
|
band_h: i32,
|
||||||
|
auto_scroll: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_params() -> Option<Params> {
|
||||||
|
let g = SESSION.lock().ok()?;
|
||||||
|
let s = g.as_ref().filter(|s| s.active)?;
|
||||||
|
Some(Params {
|
||||||
|
hwnd: s.hwnd,
|
||||||
|
cw: s.cw,
|
||||||
|
ch: s.ch,
|
||||||
|
ox: s.ox,
|
||||||
|
oy: s.oy,
|
||||||
|
band_x: s.band_x,
|
||||||
|
band_y: s.band_y,
|
||||||
|
band_w: s.band_w,
|
||||||
|
band_h: s.band_h,
|
||||||
|
auto_scroll: s.auto_scroll,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话是否仍活动(run_loop 每周期自检,异常路径提前退出)
|
||||||
|
fn is_active() -> bool {
|
||||||
|
SESSION
|
||||||
|
.lock()
|
||||||
|
.map(|g| g.as_ref().map(|s| s.active).unwrap_or(false))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动滚动截图会话。`auto = true` 时线程自动下滚拼到底部;否则等用户手动滚动。
|
||||||
|
pub fn start(app: AppHandle, hwnd: isize, region: Option<ScrollRegion>, auto: bool) -> Result<(), String> {
|
||||||
|
{
|
||||||
|
let g = SESSION.lock().map_err(|e| e.to_string())?;
|
||||||
|
if g.as_ref().map(|s| s.active).unwrap_or(false) {
|
||||||
|
return Err("已有滚动截图进行中".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (ctx, bx, by, bw, bh) = resolve_band(hwnd, region)?;
|
||||||
|
let first = capture_client_bgra(hwnd, ctx.ox, ctx.oy, ctx.cw, ctx.ch)?;
|
||||||
|
let canvas = crop_bgra(&first, ctx.cw as usize, bx, by, bw, bh)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut g = SESSION.lock().map_err(|e| e.to_string())?;
|
||||||
|
*g = Some(Session {
|
||||||
|
hwnd,
|
||||||
|
cw: ctx.cw,
|
||||||
|
ch: ctx.ch,
|
||||||
|
ox: ctx.ox,
|
||||||
|
oy: ctx.oy,
|
||||||
|
band_x: bx,
|
||||||
|
band_y: by,
|
||||||
|
band_w: bw,
|
||||||
|
band_h: bh,
|
||||||
|
canvas,
|
||||||
|
total_h: bh as u32,
|
||||||
|
ttl_px: 0,
|
||||||
|
first: first.clone(),
|
||||||
|
prev: first,
|
||||||
|
auto_scroll: auto,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
STOP.store(false, Ordering::Release);
|
||||||
|
CANCEL.store(false, Ordering::Release);
|
||||||
|
|
||||||
|
log_info("scroll-session", &format!(
|
||||||
|
"启动滚动截图 hwnd={} band={}x{} auto={}",
|
||||||
|
hwnd, bw, bh, auto
|
||||||
|
));
|
||||||
|
std::thread::spawn(move || run_loop(app));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求结束会话并导出(完成)。立即返回,线程在下一轮感知并完成。
|
||||||
|
pub fn finish() -> Result<(), String> {
|
||||||
|
if !running() {
|
||||||
|
return Err("没有进行中的滚动截图".into());
|
||||||
|
}
|
||||||
|
STOP.store(true, Ordering::Release);
|
||||||
|
CANCEL.store(false, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求取消会话(不导出)。立即返回,线程在下一轮感知并回滚终止。
|
||||||
|
pub fn cancel_now() -> Result<(), String> {
|
||||||
|
if !running() {
|
||||||
|
return Err("没有进行中的滚动截图".into());
|
||||||
|
}
|
||||||
|
STOP.store(true, Ordering::Release);
|
||||||
|
CANCEL.store(true, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running() -> bool {
|
||||||
|
SESSION.lock().map(|g| g.as_ref().map(|s| s.active).unwrap_or(false)).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滚轮线程:高频小步长发送,滚动手感交给目标程序的平滑滚动动画。
|
||||||
|
fn ticker_loop() {
|
||||||
|
loop {
|
||||||
|
if TICKER_STOP.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let target = WHEEL_TARGET.load(Ordering::Acquire);
|
||||||
|
let delta = WHEEL_STEP.load(Ordering::Acquire);
|
||||||
|
let sx = WHEEL_SX.load(Ordering::Acquire);
|
||||||
|
let sy = WHEEL_SY.load(Ordering::Acquire);
|
||||||
|
if target != 0 && delta != 0 {
|
||||||
|
if WHEEL_POST.load(Ordering::Acquire) {
|
||||||
|
post_wheel(target, sx, sy, -delta);
|
||||||
|
} else {
|
||||||
|
send_wheel(target, sx, sy, -delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(TICK_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用滚轮投递策略(未检测到位移时逐级升级,直到找到能滚动的通道)。
|
||||||
|
fn apply_strategy(strategy: u8, p: &Params, bcx: i32, bcy: i32) {
|
||||||
|
match strategy {
|
||||||
|
0 => {
|
||||||
|
// 子窗口 + 小步长(Chromium/WinUI 等会累加小步长并平滑滚动)
|
||||||
|
WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release);
|
||||||
|
WHEEL_STEP.store(DELTA_START, Ordering::Release);
|
||||||
|
WHEEL_POST.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// 子窗口 + 整格(经典 Win32 控件只认整格消息)
|
||||||
|
WHEEL_TARGET.store(deep_child_at_point(p.hwnd, bcx, bcy), Ordering::Release);
|
||||||
|
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
|
||||||
|
WHEEL_POST.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// 顶层 + 整格 Send(部分程序由顶层统一处理滚轮)
|
||||||
|
WHEEL_TARGET.store(p.hwnd, Ordering::Release);
|
||||||
|
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
|
||||||
|
WHEEL_POST.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 顶层 + 整格 Post(只处理消息泵排队事件的程序)
|
||||||
|
WHEEL_TARGET.store(p.hwnd, Ordering::Release);
|
||||||
|
WHEEL_STEP.store(DELTA_NOTCH, Ordering::Release);
|
||||||
|
WHEEL_POST.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log_info("scroll-session", &format!("滚轮策略升级为 {}", strategy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单周期匹配结果
|
||||||
|
enum CycleResult {
|
||||||
|
/// 两帧一致(未滚动)
|
||||||
|
NoChange,
|
||||||
|
/// 向下滚了 d 像素(已拼接)
|
||||||
|
Moved(usize),
|
||||||
|
/// 匹配失败(动画 / 位移超上限)
|
||||||
|
MatchFail,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一轮捕获-匹配-拼接。返回 (band_w, total_h, CycleResult)。
|
||||||
|
fn stitch_cycle(p: &Params, cur: &[u8]) -> (i32, u32, CycleResult) {
|
||||||
|
let mut g = match SESSION.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(e) => {
|
||||||
|
log_error("scroll-session", &format!("会话锁异常: {}", e));
|
||||||
|
return (p.band_w, 0, CycleResult::MatchFail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let s = match g.as_mut() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return (p.band_w, 0, CycleResult::MatchFail),
|
||||||
|
};
|
||||||
|
let prev = std::mem::take(&mut s.prev);
|
||||||
|
let (bx, by, bw, bh) = (s.band_x, s.band_y, s.band_w, s.band_h);
|
||||||
|
let off = detect_vscroll_offset(
|
||||||
|
&prev,
|
||||||
|
cur,
|
||||||
|
p.cw as usize,
|
||||||
|
p.ch as usize,
|
||||||
|
bx as usize,
|
||||||
|
by as usize,
|
||||||
|
bw as usize,
|
||||||
|
bh as usize,
|
||||||
|
);
|
||||||
|
match off {
|
||||||
|
Some(0) => {
|
||||||
|
s.prev = cur.to_vec();
|
||||||
|
(bw, s.total_h, CycleResult::NoChange)
|
||||||
|
}
|
||||||
|
Some(d) => {
|
||||||
|
// 一次最多拼接带的整高:单步滚动量超过带高时无法恢复中间内容,
|
||||||
|
// 以整带兜底(自适应速度会把位移压回安全区间)
|
||||||
|
let new_rows = (d as i32).min(bh) as usize;
|
||||||
|
let new_y = bh - new_rows as i32; // 带在客户区内的底对齐
|
||||||
|
match crop_bgra(cur, p.cw as usize, bx, s.band_y + new_y, bw, new_rows as i32) {
|
||||||
|
Ok(rows) => {
|
||||||
|
s.canvas.extend_from_slice(&rows);
|
||||||
|
s.total_h += new_rows as u32;
|
||||||
|
s.ttl_px += d as u32;
|
||||||
|
s.prev = cur.to_vec();
|
||||||
|
(bw, s.total_h, CycleResult::Moved(d))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
s.prev = cur.to_vec();
|
||||||
|
(bw, s.total_h, CycleResult::MatchFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// 匹配失败:保留旧基准帧(下一帧与更早的稳定帧比对,累积位移仍能对上)
|
||||||
|
s.prev = prev;
|
||||||
|
(bw, s.total_h, CycleResult::MatchFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop(app: AppHandle) {
|
||||||
|
let p = match read_params() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 滚轮目标初始参数:带中心(客户区坐标)→ 屏幕坐标
|
||||||
|
let bcx = p.band_x + p.band_w / 2;
|
||||||
|
let bcy = p.band_y + p.band_h / 2;
|
||||||
|
let (bsx, bsy) = client_pt_to_screen(p.hwnd, bcx, bcy).unwrap_or((0, 0));
|
||||||
|
WHEEL_SX.store(bsx, Ordering::Release);
|
||||||
|
WHEEL_SY.store(bsy, Ordering::Release);
|
||||||
|
|
||||||
|
// 自动模式:启动滚轮线程(策略 0 起步)
|
||||||
|
let mut strategy: u8 = 0;
|
||||||
|
let mut ticker = None;
|
||||||
|
if p.auto_scroll {
|
||||||
|
apply_strategy(0, &p, bcx, bcy);
|
||||||
|
TICKER_STOP.store(false, Ordering::Release);
|
||||||
|
ticker = Some(std::thread::spawn(ticker_loop));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut no_change: u32 = 0; // 锁定后:连续无位移(到底判定)
|
||||||
|
let mut no_move: u32 = 0; // 未锁定:连续无位移(策略升级判定)
|
||||||
|
let mut locked = false; // 是否已确认当前策略能滚动
|
||||||
|
let mut gave_up = false; // 所有策略都滚不动
|
||||||
|
let mut cycle_ms = CYCLE_MS;
|
||||||
|
let mut capture_errors: u32 = 0;
|
||||||
|
let mut last_emit = Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if STOP.load(Ordering::Acquire) || !is_active() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 周期节拍:捕获+匹配耗时计入周期
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) {
|
||||||
|
Ok(f) => {
|
||||||
|
capture_errors = 0;
|
||||||
|
f
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// 瞬时失败先重试,连续多次失败才视为窗口关闭/失去客户区
|
||||||
|
capture_errors += 1;
|
||||||
|
log_error("scroll-session", &format!("捕获失败({}/3): {}", capture_errors, e));
|
||||||
|
if capture_errors >= 3 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (band_w, total_h, result) = stitch_cycle(&p, &cur);
|
||||||
|
|
||||||
|
// 目标位移:带高 1/4,夹在安全区间
|
||||||
|
let target_d = (p.band_h as usize / 4).clamp(TARGET_D_MIN, TARGET_D_MAX);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
CycleResult::Moved(d) => {
|
||||||
|
if !locked {
|
||||||
|
locked = true;
|
||||||
|
log_info("scroll-session", &format!("策略 {} 生效,锁定", strategy));
|
||||||
|
}
|
||||||
|
no_change = 0;
|
||||||
|
no_move = 0;
|
||||||
|
if p.auto_scroll {
|
||||||
|
if strategy == 0 {
|
||||||
|
// 小步长模式:按实测位移调步长(sqrt 阻尼防过冲)
|
||||||
|
let delta = WHEEL_STEP.load(Ordering::Acquire) as f64;
|
||||||
|
let factor = target_d as f64 / d.max(6) as f64;
|
||||||
|
let nd = (delta * factor.sqrt()).clamp(DELTA_MIN as f64, DELTA_MAX as f64);
|
||||||
|
WHEEL_STEP.store(nd as i32, Ordering::Release);
|
||||||
|
} else {
|
||||||
|
// 整格模式:步长固定,按实测位移调捕获周期
|
||||||
|
if d as f64 > p.band_h as f64 * 0.55 {
|
||||||
|
cycle_ms = ((cycle_ms as f64) * 1.25).min(CYCLE_MS_MAX as f64) as u64;
|
||||||
|
} else if d < target_d / 2 && cycle_ms > CYCLE_MS {
|
||||||
|
cycle_ms = ((cycle_ms as f64) * 0.8).max(CYCLE_MS as f64) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CycleResult::NoChange => {
|
||||||
|
if locked {
|
||||||
|
no_change += 1;
|
||||||
|
if no_change >= NO_CHANGE_STOP && p.auto_scroll {
|
||||||
|
break; // 已到底部
|
||||||
|
}
|
||||||
|
// 手动模式由用户控制,不自动结束
|
||||||
|
} else if p.auto_scroll {
|
||||||
|
no_move += 1;
|
||||||
|
if no_move >= ESCALATE_CYCLES {
|
||||||
|
no_move = 0;
|
||||||
|
strategy += 1;
|
||||||
|
if strategy > 3 {
|
||||||
|
gave_up = true; // 所有通道都滚不动 → 结束(前端提示)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
apply_strategy(strategy, &p, bcx, bcy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CycleResult::MatchFail => {
|
||||||
|
// 内容大幅变化/动画/位移超上限:若持续失败则降速,
|
||||||
|
// 避免越滚越快导致匹配一直失败(表现即"滚动中断")
|
||||||
|
if p.auto_scroll {
|
||||||
|
if locked && strategy == 0 {
|
||||||
|
let delta = WHEEL_STEP.load(Ordering::Acquire) as f64;
|
||||||
|
let nd = (delta * 0.75).max(DELTA_MIN as f64);
|
||||||
|
WHEEL_STEP.store(nd as i32, Ordering::Release);
|
||||||
|
} else if !locked {
|
||||||
|
// 未锁定:匹配失败也算"无位移"参与策略升级,
|
||||||
|
// 防止动画页面导致升级判定永不触发
|
||||||
|
no_move += 1;
|
||||||
|
if no_move >= ESCALATE_CYCLES {
|
||||||
|
no_move = 0;
|
||||||
|
strategy += 1;
|
||||||
|
if strategy > 3 {
|
||||||
|
gave_up = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
apply_strategy(strategy, &p, bcx, bcy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进度事件(节流)
|
||||||
|
if matches!(result, CycleResult::Moved(_))
|
||||||
|
&& last_emit.elapsed().as_millis() as u64 >= PROGRESS_THROTTLE_MS
|
||||||
|
{
|
||||||
|
last_emit = Instant::now();
|
||||||
|
emit_progress(&app, band_w, total_h, p.auto_scroll);
|
||||||
|
}
|
||||||
|
if total_h >= MAX_TOTAL_H {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补足周期剩余时间
|
||||||
|
let el = t0.elapsed().as_millis() as u64;
|
||||||
|
if el < cycle_ms {
|
||||||
|
sleep(Duration::from_millis(cycle_ms - el));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停滚轮线程(join 等它退出,最多再发一拍)
|
||||||
|
if let Some(t) = ticker.take() {
|
||||||
|
TICKER_STOP.store(true, Ordering::Release);
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel = CANCEL.load(Ordering::Acquire);
|
||||||
|
// 完成(非取消、非放弃)时:等滚动动画静止后补拼最后一帧,
|
||||||
|
// 保证图片结尾 = 用户停止时窗口实际停留的位置
|
||||||
|
if !cancel && !gave_up {
|
||||||
|
settle_and_stitch(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取出会话数据
|
||||||
|
let mut s = match SESSION.lock().ok().and_then(|mut g| g.take()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
log_info("scroll-session", "会话已不存在(未导出)");
|
||||||
|
let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编码与回滚并行:PNG 编码只依赖画布(已定格),回滚不依赖画布,
|
||||||
|
// 两者无数据依赖,并行可显著缩短「停止 → 打开编辑器」的等待
|
||||||
|
let (band_w, total_h) = (s.band_w, s.total_h);
|
||||||
|
let encode = if cancel {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let canvas = std::mem::take(&mut s.canvas);
|
||||||
|
Some(std::thread::spawn(move || {
|
||||||
|
bgra_to_png(&canvas, band_w, total_h as i32)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// 回滚到起始位置(与编码并行):先按累计像素粗估上滚,再帧匹配校准
|
||||||
|
if s.ttl_px > 0 {
|
||||||
|
rollback(&s);
|
||||||
|
}
|
||||||
|
|
||||||
|
STOP.store(false, Ordering::Release);
|
||||||
|
CANCEL.store(false, Ordering::Release);
|
||||||
|
|
||||||
|
// 等编码完成:PNG 原始字节直接存入编辑器图片槽(不经 base64 / JSON 事件传输)
|
||||||
|
let png = encode.and_then(|h| h.join().ok()).and_then(|r| r.ok());
|
||||||
|
match png {
|
||||||
|
Some(png) => {
|
||||||
|
log_info("scroll-session", &format!(
|
||||||
|
"完成导出 width={} height={}",
|
||||||
|
band_w, total_h
|
||||||
|
));
|
||||||
|
super::set_editor_image_raw(png);
|
||||||
|
let _ = app.emit(
|
||||||
|
evts::SCROLL_COMPLETE,
|
||||||
|
serde_json::json!({ "width": band_w, "height": total_h }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log_info("scroll-session", "会话已取消(未导出)");
|
||||||
|
let _ = app.emit(evts::SCROLL_CANCELLED, serde_json::json!({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止后等待目标窗口滚动动画静止,并把静止帧补拼进画布(结尾对齐停止位置)。
|
||||||
|
fn settle_and_stitch(p: &Params) {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(SETTLE_TIMEOUT_MS);
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_millis(70));
|
||||||
|
if Instant::now() >= deadline || !is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cur = match capture_client_bgra(p.hwnd, p.ox, p.oy, p.cw, p.ch) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
match stitch_cycle(p, &cur).2 {
|
||||||
|
CycleResult::NoChange => return, // 已静止且无新内容
|
||||||
|
CycleResult::Moved(_) | CycleResult::MatchFail => {
|
||||||
|
// 动画仍在进行(Moved 已拼接;MatchFail 继续等)
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回滚:粗估整格数上滚 → 与初始帧逐次比对校准,直到回到起始位置。
|
||||||
|
/// 帧匹配校准消除"每格滚动像素因程序而异"带来的累计误差。
|
||||||
|
fn rollback(s: &Session) {
|
||||||
|
let target = {
|
||||||
|
let t = WHEEL_TARGET.load(Ordering::Acquire);
|
||||||
|
if t != 0 { t } else { s.hwnd }
|
||||||
|
};
|
||||||
|
let mut sx = WHEEL_SX.load(Ordering::Acquire);
|
||||||
|
let mut sy = WHEEL_SY.load(Ordering::Acquire);
|
||||||
|
if sx == 0 && sy == 0 {
|
||||||
|
if let Some((x, y)) = client_pt_to_screen(
|
||||||
|
s.hwnd,
|
||||||
|
s.band_x + s.band_w / 2,
|
||||||
|
s.band_y + s.band_h / 2,
|
||||||
|
) {
|
||||||
|
sx = x;
|
||||||
|
sy = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粗估上滚(宁可略少,剩余交给校准补齐)。
|
||||||
|
// 上限按累计像素推算:长图捕获(累计数万 px)也要能回滚到位,
|
||||||
|
// 固定小上限会导致超长捕获结束后窗口停在半途。
|
||||||
|
let notches = (((s.ttl_px as f64) / ROLLBACK_PX_PER_NOTCH).floor() as i32).clamp(1, 800);
|
||||||
|
for _ in 0..notches {
|
||||||
|
send_wheel(target, sx, sy, WHEEL_DELTA as i32);
|
||||||
|
sleep(Duration::from_millis(3));
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(80));
|
||||||
|
|
||||||
|
// 帧匹配校准:d = 当前仍相对起始位置向下滚动的像素
|
||||||
|
let mut last_d = usize::MAX;
|
||||||
|
for _ in 0..12 {
|
||||||
|
let cur = match capture_client_bgra(s.hwnd, s.ox, s.oy, s.cw, s.ch) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let d = detect_vscroll_offset(
|
||||||
|
&s.first,
|
||||||
|
&cur,
|
||||||
|
s.cw as usize,
|
||||||
|
s.ch as usize,
|
||||||
|
s.band_x as usize,
|
||||||
|
s.band_y as usize,
|
||||||
|
s.band_w as usize,
|
||||||
|
s.band_h as usize,
|
||||||
|
);
|
||||||
|
match d {
|
||||||
|
Some(0) | None => return, // 已回到起始位置 / 无法匹配(视为完成)
|
||||||
|
Some(d) if d < 10 => return, // 误差 10px 内视为到位
|
||||||
|
Some(d) => {
|
||||||
|
if d >= last_d {
|
||||||
|
return; // 不再下降:窗口可能不支持向上滚(虚拟化列表),放弃
|
||||||
|
}
|
||||||
|
last_d = d;
|
||||||
|
let more = (((d as f64) / ROLLBACK_PX_PER_NOTCH).ceil() as i32).clamp(1, 60);
|
||||||
|
for _ in 0..more {
|
||||||
|
send_wheel(target, sx, sy, WHEEL_DELTA as i32);
|
||||||
|
sleep(Duration::from_millis(3));
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_progress(app: &AppHandle, width: i32, height: u32, auto: bool) {
|
||||||
|
let _ = app.emit(
|
||||||
|
evts::SCROLL_PROGRESS,
|
||||||
|
serde_json::json!({ "width": width, "height": height, "auto": auto }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
//! HDR 全屏捕获实现(Windows Graphics Capture + HDR→sRGB 色调映射)
|
||||||
|
//!
|
||||||
|
//! ## 为什么需要它
|
||||||
|
//! Windows 高级色彩(HDR)开启时,桌面由 DWM 用**线性 scRGB(RGBA16F)**合成。
|
||||||
|
//! [crate::screenshot::capture] 的 `BitBlt` 从屏幕 DC 直接取出 8bit 像素,
|
||||||
|
//! 把这些线性/scRGB 高亮值当 sRGB 编码,会导致亮部越界被裁、局部过曝失真。
|
||||||
|
//!
|
||||||
|
//! ## 方案
|
||||||
|
//! 检测到任意显示器为 HDR 输出时,改用 WGC:
|
||||||
|
//! 1. 以 `RGBA16F`(scRGB 线性)捕获每个显示器;
|
||||||
|
//! 2. 按 SDR 参考白缩放(`÷ (sdr_white_nits / 80)`)后钳制到 [0,1];
|
||||||
|
//! 3. 做 sRGB 伽马编码,得到与屏幕观感一致的 BGRA8;
|
||||||
|
//! 4. 按各显示器在虚拟屏上的偏移拼接到一整张虚拟屏图像(与 BitBlt 输出同构)。
|
||||||
|
//!
|
||||||
|
//! 仅当 HDR 时才走此路径;SDR 显示器仍用快速的 BitBlt。
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use windows_capture::capture::{Context, GraphicsCaptureApiHandler};
|
||||||
|
use windows_capture::frame::Frame;
|
||||||
|
use windows_capture::graphics_capture_api::InternalCaptureControl;
|
||||||
|
use windows_capture::monitor::Monitor;
|
||||||
|
use windows_capture::settings::{
|
||||||
|
ColorFormat, CursorCaptureSettings, DirtyRegionSettings, DrawBorderSettings,
|
||||||
|
MinimumUpdateIntervalSettings, SecondaryWindowSettings, Settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::capture::CapturedImage;
|
||||||
|
|
||||||
|
// ===== 虚拟屏尺寸(与 capture.rs 一致)=====
|
||||||
|
const SM_XVIRTUALSCREEN: i32 = 76;
|
||||||
|
const SM_YVIRTUALSCREEN: i32 = 77;
|
||||||
|
const SM_CXVIRTUALSCREEN: i32 = 78;
|
||||||
|
const SM_CYVIRTUALSCREEN: i32 = 79;
|
||||||
|
|
||||||
|
/// Windows「SDR 内容亮度」的默认参考白(nits)。未自定义时为 203。
|
||||||
|
/// 严格值可用 DISPLAYCONFIG_SDR_WHITE_LEVEL 查询;此处取系统默认,覆盖绝大多数情况。
|
||||||
|
const DEFAULT_SDR_WHITE_NITS: f32 = 203.0;
|
||||||
|
|
||||||
|
/// 单帧捕获参数(作为 WGC handler 的 Flags 传入,把抓到的帧回传给调用线程)
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CaptureFlags {
|
||||||
|
tx: mpsc::SyncSender<Result<FramePixels, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一帧 Rgba16F 像素(已去掉行尾 padding,top-down)
|
||||||
|
struct FramePixels {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
raw: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性截图 handler:拿到第一帧即回传并停止捕获
|
||||||
|
struct OneShot {
|
||||||
|
flags: CaptureFlags,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraphicsCaptureApiHandler for OneShot {
|
||||||
|
type Flags = CaptureFlags;
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn new(ctx: Context<Self::Flags>) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self { flags: ctx.flags })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_frame_arrived(
|
||||||
|
&mut self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
capture_control: InternalCaptureControl,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
let mut buffer = frame.buffer().map_err(|e| e.to_string())?;
|
||||||
|
let width = buffer.width();
|
||||||
|
let height = buffer.height();
|
||||||
|
let row_pitch = buffer.row_pitch() as usize;
|
||||||
|
let raw = buffer.as_raw_buffer();
|
||||||
|
// 拷贝并去掉行 padding(Rgba16F = 每像素 8 字节)
|
||||||
|
let row_bytes = (width as usize) * 8;
|
||||||
|
let mut packed = vec![0u8; row_bytes * (height as usize)];
|
||||||
|
for y in 0..(height as usize) {
|
||||||
|
let src = y * row_pitch;
|
||||||
|
let dst = y * row_bytes;
|
||||||
|
packed[dst..dst + row_bytes].copy_from_slice(&raw[src..src + row_bytes]);
|
||||||
|
}
|
||||||
|
let _ = self.flags.tx.send(Ok(FramePixels {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
raw: packed,
|
||||||
|
}));
|
||||||
|
// 单帧足够,立即结束捕获(internal stop → 捕获线程退出)
|
||||||
|
capture_control.stop();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获单个显示器(按 hmonitor),返回 tonemap 后的 BGRA8(top-down)
|
||||||
|
fn capture_monitor_bgra(
|
||||||
|
hmonitor: *mut c_void,
|
||||||
|
sdr_white: f32,
|
||||||
|
) -> Result<(Vec<u8>, u32, u32), String> {
|
||||||
|
let monitor = Monitor::from_raw_hmonitor(hmonitor);
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Result<FramePixels, String>>(1);
|
||||||
|
let flags = CaptureFlags { tx };
|
||||||
|
let settings = Settings::new(
|
||||||
|
monitor,
|
||||||
|
CursorCaptureSettings::WithCursor,
|
||||||
|
DrawBorderSettings::WithoutBorder,
|
||||||
|
SecondaryWindowSettings::Default,
|
||||||
|
MinimumUpdateIntervalSettings::Custom(Duration::from_millis(32)),
|
||||||
|
DirtyRegionSettings::Default,
|
||||||
|
ColorFormat::Rgba16F,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
|
||||||
|
let control = OneShot::start_free_threaded(settings)
|
||||||
|
.map_err(|e| format!("WGC 捕获启动失败: {}", e))?;
|
||||||
|
|
||||||
|
let frame = match rx.recv_timeout(Duration::from_secs(10)) {
|
||||||
|
Ok(Ok(f)) => f,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
let _ = control.stop();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = control.stop();
|
||||||
|
return Err(format!("等待 WGC 帧超时: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = control.stop();
|
||||||
|
|
||||||
|
let bgra = tonemap_rgba16f_to_bgra(&frame.raw, frame.width, frame.height, sdr_white);
|
||||||
|
Ok((bgra, frame.width, frame.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获整个虚拟屏(多显示器拼接),返回与 BitBlt 同构的 `CapturedImage`(BGRA top-down)。
|
||||||
|
/// 仅当检测到 HDR 时由 [capture] 调用。
|
||||||
|
pub fn capture_virtual_screen_wgc() -> Result<CapturedImage, String> {
|
||||||
|
// 用 QueryDisplayConfig 读系统实际 SDR 白电平(nits),取不到才回退系统默认 203
|
||||||
|
let sdr_white = query_sdr_white_level().unwrap_or(DEFAULT_SDR_WHITE_NITS);
|
||||||
|
let monitors = enum_monitors();
|
||||||
|
if monitors.is_empty() {
|
||||||
|
return Err("未检测到显示器".into());
|
||||||
|
}
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::GetSystemMetrics as gsm;
|
||||||
|
let vx = unsafe { gsm(SM_XVIRTUALSCREEN) };
|
||||||
|
let vy = unsafe { gsm(SM_YVIRTUALSCREEN) };
|
||||||
|
let vw = unsafe { gsm(SM_CXVIRTUALSCREEN) };
|
||||||
|
let vh = unsafe { gsm(SM_CYVIRTUALSCREEN) };
|
||||||
|
if vw <= 0 || vh <= 0 {
|
||||||
|
return Err("无法获取虚拟屏尺寸".into());
|
||||||
|
}
|
||||||
|
let mut canvas = vec![0u8; (vw as usize) * (vh as usize) * 4]; // 透明区以黑填充
|
||||||
|
for m in &monitors {
|
||||||
|
let (bgra, mw, mh) = capture_monitor_bgra(m.handle as *mut c_void, sdr_white)?;
|
||||||
|
let ow = m.x - vx;
|
||||||
|
let oh = m.y - vy;
|
||||||
|
if mw as i32 != m.w || mh as i32 != m.h {
|
||||||
|
// 尺寸失配(极少见,如缩放中途)— 中止避免错位拼接
|
||||||
|
return Err(format!(
|
||||||
|
"显示器捕获尺寸不符: 枚举 {}x{} vs WGC {}x{}",
|
||||||
|
m.w, m.h, mw, mh
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mw = mw as usize;
|
||||||
|
for row in 0..mh {
|
||||||
|
let src = (row as usize) * mw * 4;
|
||||||
|
let dst = ((oh + row as i32) as usize) * (vw as usize) * 4 + (ow as usize) * 4;
|
||||||
|
let len = mw * 4;
|
||||||
|
canvas[dst..dst + len].copy_from_slice(&bgra[src..src + len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CapturedImage {
|
||||||
|
width: vw,
|
||||||
|
height: vh,
|
||||||
|
png: Vec::new(),
|
||||||
|
bgra: canvas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 显示器枚举与 HDR 检测 =====
|
||||||
|
|
||||||
|
struct MonInfo {
|
||||||
|
handle: usize,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enum_monitors() -> Vec<MonInfo> {
|
||||||
|
let mut out: Vec<MonInfo> = Vec::new();
|
||||||
|
unsafe extern "system" fn cb(
|
||||||
|
hmon: isize,
|
||||||
|
_hdc: isize,
|
||||||
|
rect: *mut windows_sys::Win32::Foundation::RECT,
|
||||||
|
lparam: isize,
|
||||||
|
) -> windows_sys::Win32::Foundation::BOOL {
|
||||||
|
let v = &mut *(lparam as *mut Vec<MonInfo>);
|
||||||
|
let r = *rect;
|
||||||
|
v.push(MonInfo {
|
||||||
|
handle: hmon as usize,
|
||||||
|
x: r.left,
|
||||||
|
y: r.top,
|
||||||
|
w: r.right - r.left,
|
||||||
|
h: r.bottom - r.top,
|
||||||
|
});
|
||||||
|
1
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
windows_sys::Win32::Graphics::Gdi::EnumDisplayMonitors(
|
||||||
|
0,
|
||||||
|
std::ptr::null(),
|
||||||
|
Some(cb),
|
||||||
|
&mut out as *mut _ as isize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 枚举 DXGI 输出,任一显示器为 HDR(advanced color / PQ / HLG)输出则返回 true。
|
||||||
|
/// 任何错误一律视为非 HDR(回退 BitBlt),保证普通 SDR 环境不受影响。
|
||||||
|
pub fn is_hdr_enabled() -> bool {
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
|
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020,
|
||||||
|
DXGI_COLOR_SPACE_TYPE,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn is_hdr_space(space: DXGI_COLOR_SPACE_TYPE) -> bool {
|
||||||
|
// HDR10(PQ,BT.2020 主色)与 scRGB 高级色彩工作空间即视为 HDR
|
||||||
|
matches!(
|
||||||
|
space,
|
||||||
|
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 | DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
use windows::Win32::Graphics::Dxgi::IDXGIFactory1;
|
||||||
|
let Ok(factory) = (unsafe {
|
||||||
|
windows::Win32::Graphics::Dxgi::CreateDXGIFactory1::<IDXGIFactory1>()
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
use windows::core::ComInterface;
|
||||||
|
unsafe {
|
||||||
|
let mut ai = 0u32;
|
||||||
|
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||||
|
ai += 1;
|
||||||
|
let mut oi = 0u32;
|
||||||
|
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||||
|
oi += 1;
|
||||||
|
if let Ok(out6) =
|
||||||
|
output.cast::<windows::Win32::Graphics::Dxgi::IDXGIOutput6>()
|
||||||
|
{
|
||||||
|
let mut desc: windows::Win32::Graphics::Dxgi::DXGI_OUTPUT_DESC1 =
|
||||||
|
std::mem::zeroed();
|
||||||
|
if out6.GetDesc1(&mut desc).is_ok() && is_hdr_space(desc.ColorSpace) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过 QueryDisplayConfig 读取 Windows 实际的 SDR 参考白电平(nits)。
|
||||||
|
///
|
||||||
|
/// SDR 白电平并非 union 的声明成员,而是系统/驱动写入在 DISPLAYCONFIG_MODE_INFO
|
||||||
|
/// 的 union 尾部(desktopImageInfo 之后)的 4 字节(DISPLAYCONFIG_SDR_WHITE_LEVEL)。
|
||||||
|
/// 取不到(非 HDR / 查询失败)时返回 None,由调用方回退系统默认 203。
|
||||||
|
fn query_sdr_white_level() -> Option<f32> {
|
||||||
|
use windows_sys::Win32::Devices::Display::{
|
||||||
|
GetDisplayConfigBufferSizes, QueryDisplayConfig, DISPLAYCONFIG_MODE_INFO,
|
||||||
|
DISPLAYCONFIG_MODE_INFO_TYPE_DESKTOP_IMAGE, DISPLAYCONFIG_PATH_INFO,
|
||||||
|
DISPLAYCONFIG_SDR_WHITE_LEVEL, QDC_ONLY_ACTIVE_PATHS,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let mut num_paths = 0u32;
|
||||||
|
let mut num_modes = 0u32;
|
||||||
|
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut num_paths, &mut num_modes) != 0
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut paths = vec![std::mem::zeroed::<DISPLAYCONFIG_PATH_INFO>(); num_paths as usize];
|
||||||
|
let mut modes = vec![std::mem::zeroed::<DISPLAYCONFIG_MODE_INFO>(); num_modes as usize];
|
||||||
|
if QueryDisplayConfig(
|
||||||
|
QDC_ONLY_ACTIVE_PATHS,
|
||||||
|
&mut num_paths,
|
||||||
|
paths.as_mut_ptr(),
|
||||||
|
&mut num_modes,
|
||||||
|
modes.as_mut_ptr(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
) != 0
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for mode in modes.iter().take(num_modes as usize) {
|
||||||
|
if mode.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_DESKTOP_IMAGE {
|
||||||
|
let base = mode as *const DISPLAYCONFIG_MODE_INFO as *const u8;
|
||||||
|
let off = std::mem::size_of::<DISPLAYCONFIG_MODE_INFO>()
|
||||||
|
- std::mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>();
|
||||||
|
let white = std::ptr::read_unaligned::<u32>(base.add(off) as *const u32);
|
||||||
|
if white > 0 {
|
||||||
|
return Some(white as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HDR→sRGB 色调映射 =====
|
||||||
|
|
||||||
|
/// IEEE754 半精度 float → f32
|
||||||
|
fn half_to_f32(h: u16) -> f32 {
|
||||||
|
let sign = (h & 0x8000) != 0;
|
||||||
|
let exp = (h >> 10) & 0x1f;
|
||||||
|
let man = (h & 0x3ff) as f32;
|
||||||
|
let v = match exp {
|
||||||
|
0 => man / 1024.0 * 2.0f32.powi(-14), // 次正规
|
||||||
|
0x1f => f32::NAN, // Inf/NaN,按 NaN 处理(后续 clamp 为 0~255 安全)
|
||||||
|
_ => (1.0 + man / 1024.0) * 2.0f32.powi(exp as i32 - 15),
|
||||||
|
};
|
||||||
|
if sign {
|
||||||
|
-v
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线性 scRGB 值 → sRGB 8bit(标准 sRGB OETF)
|
||||||
|
fn srgb_encode(linear: f32) -> u8 {
|
||||||
|
let l = linear.clamp(0.0, 1.0);
|
||||||
|
let e = if l <= 0.003_130_8 {
|
||||||
|
12.92 * l
|
||||||
|
} else {
|
||||||
|
1.055 * l.powf(1.0 / 2.4) - 0.055
|
||||||
|
};
|
||||||
|
(e * 255.0 + 0.5) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rgba16F(scRGB 线性,top-down)→ BGRA8(sRGB),按 SDR 参考白缩放。
|
||||||
|
fn tonemap_rgba16f_to_bgra(raw: &[u8], width: u32, height: u32, sdr_white: f32) -> Vec<u8> {
|
||||||
|
let inv_scale = 80.0 / sdr_white.max(1.0);
|
||||||
|
let n = (width as usize) * (height as usize);
|
||||||
|
let mut out = vec![0u8; n * 4];
|
||||||
|
for i in 0..n {
|
||||||
|
let pi = i * 8;
|
||||||
|
let r16 = u16::from_le_bytes([raw[pi], raw[pi + 1]]);
|
||||||
|
let g16 = u16::from_le_bytes([raw[pi + 2], raw[pi + 3]]);
|
||||||
|
let b16 = u16::from_le_bytes([raw[pi + 4], raw[pi + 5]]);
|
||||||
|
let b = srgb_encode(half_to_f32(b16) * inv_scale);
|
||||||
|
let g = srgb_encode(half_to_f32(g16) * inv_scale);
|
||||||
|
let r = srgb_encode(half_to_f32(r16) * inv_scale);
|
||||||
|
let oi = i * 4;
|
||||||
|
out[oi] = b;
|
||||||
|
out[oi + 1] = g;
|
||||||
|
out[oi + 2] = r;
|
||||||
|
out[oi + 3] = 255; // 强制不透明(与 BitBlt 路径一致)
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! 敏感串的统一存放处(Windows 系统凭据管理器,DPAPI 保护)。
|
||||||
|
//!
|
||||||
|
//! 从 `music::secrets` 抽出为 crate 级公共模块。抽出的理由:本项目早期出现过
|
||||||
|
//! **两种安全姿态**——WebDAV 账号密码走凭据管理器,而飞牛登录 token 与 QQ 音乐
|
||||||
|
//! Cookie 明文躺在 `settings.json` / localStorage。同样是可冒充身份的凭据,不该
|
||||||
|
//! 区别对待。此后翻译模块又要接入多家 AI 的 API Key,若每个模块自带一套实现,
|
||||||
|
//! 姿态只会再次分叉,因此把「原语」收敛到这里,各模块只保留自己的键名约定。
|
||||||
|
//!
|
||||||
|
//! 约定:
|
||||||
|
//! - 一律使用 `Thing` 作为凭据服务名,`key` 作为用户名(Entry 的 account)。
|
||||||
|
//! **服务名不可更改**:已有凭据(`webdav-credentials`、`music-feiniu-token-*`、
|
||||||
|
//! `music-qq-cookie`)都以它存盘,改动会导致这些凭据读不到。
|
||||||
|
//! - 明文只允许存在于内存与系统凭据库,禁止回写 `settings.json` / localStorage。
|
||||||
|
//! - 迁移采用「先写凭据库成功、再清明文」的顺序;**写失败时保留明文**,
|
||||||
|
//! 宁可牺牲一致性也不能把用户已登录的会话弄丢。
|
||||||
|
//! - 非 Windows 平台没有凭据管理器:读取返回 None、写入报错,
|
||||||
|
//! 调用方据此退化为「明文存设置」(功能优先)。
|
||||||
|
|
||||||
|
/// 凭据服务名(**历史值,不可更改**)。
|
||||||
|
pub const SERVICE: &str = "Thing";
|
||||||
|
|
||||||
|
/// 读取明文(未配置 → `Ok(None)`)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn secret_read(key: &str) -> Result<Option<String>, String> {
|
||||||
|
let entry =
|
||||||
|
keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(v) => Ok(Some(v)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(format!("读取凭据失败: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入明文(覆盖式)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn secret_write(key: &str, value: &str) -> Result<(), String> {
|
||||||
|
let entry =
|
||||||
|
keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?;
|
||||||
|
entry
|
||||||
|
.set_password(value)
|
||||||
|
.map_err(|e| format!("保存凭据失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除凭据(不存在视为成功)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn secret_delete(key: &str) -> Result<(), String> {
|
||||||
|
let entry =
|
||||||
|
keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(format!("删除凭据失败: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn secret_read(_key: &str) -> Result<Option<String>, String> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn secret_write(_key: &str, _value: &str) -> Result<(), String> {
|
||||||
|
Err("当前平台不支持系统凭据管理器".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn secret_delete(_key: &str) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把明文**尽力**迁入凭据库(失败只记日志,不抛错)。
|
||||||
|
///
|
||||||
|
/// 返回「明文是否可以安全清除」:只有写入成功才为 true。
|
||||||
|
/// 调用方用这个返回值决定要不要清空内存/配置里的明文。
|
||||||
|
pub fn try_store(key: &str, value: &str) -> bool {
|
||||||
|
if value.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match secret_write(key, value) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_error(
|
||||||
|
"secrets",
|
||||||
|
&format!("凭据 {key} 写入系统凭据管理器失败(保留明文作为降级): {e}"),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取明文,失败或未配置一律退化为空串(调用方无需区分「没配」与「读不到」)。
|
||||||
|
pub fn read_or_empty(key: &str) -> String {
|
||||||
|
secret_read(key).ok().flatten().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 掩码展示:保留前 3 位与后 4 位,中间以圆点替代。
|
||||||
|
/// 长度不足时全部打码,绝不泄露完整明文。
|
||||||
|
pub fn mask(value: &str) -> String {
|
||||||
|
let v = value.trim();
|
||||||
|
let n = v.chars().count();
|
||||||
|
if n == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if n <= 8 {
|
||||||
|
return "•".repeat(n);
|
||||||
|
}
|
||||||
|
let head: String = v.chars().take(3).collect();
|
||||||
|
let tail: String = v.chars().skip(n - 4).collect();
|
||||||
|
format!("{head}••••{tail}")
|
||||||
|
}
|
||||||
+31
-13
@@ -7,6 +7,7 @@
|
|||||||
//! - 下载:DownloadEngine + 扩展 HTTP API 服务
|
//! - 下载:DownloadEngine + 扩展 HTTP API 服务
|
||||||
//! - 剪贴板:ClipboardManager + 快捷键 + 预创建弹窗
|
//! - 剪贴板:ClipboardManager + 快捷键 + 预创建弹窗
|
||||||
//! - 快速面板:快捷键 + 预创建弹窗 + 文件索引
|
//! - 快速面板:快捷键 + 预创建弹窗 + 文件索引
|
||||||
|
//! - 翻译:TranslateManager(设置与密钥按需读取,启动时不发网络请求)
|
||||||
//! - 托盘:自定义菜单窗口
|
//! - 托盘:自定义菜单窗口
|
||||||
//! - 进程:监控线程
|
//! - 进程:监控线程
|
||||||
|
|
||||||
@@ -17,8 +18,10 @@ use crate::download_engine::{DownloadEngine, ExtensionServer};
|
|||||||
use crate::logger::LogManager;
|
use crate::logger::LogManager;
|
||||||
use crate::mihomo_manager::MihomoManager;
|
use crate::mihomo_manager::MihomoManager;
|
||||||
use crate::monitor_kernel::{MonitorKernel, check_and_relaunch_if_needed};
|
use crate::monitor_kernel::{MonitorKernel, check_and_relaunch_if_needed};
|
||||||
|
use crate::music::MusicManager;
|
||||||
use crate::network_monitor::NetworkMonitor;
|
use crate::network_monitor::NetworkMonitor;
|
||||||
use crate::process_manager::{ProcessManager, start_monitoring_thread};
|
use crate::process_manager::{ProcessManager, start_monitoring_thread};
|
||||||
|
use crate::translate::TranslateManager;
|
||||||
|
|
||||||
/// 应用启动初始化入口(setup 闭包调用)。
|
/// 应用启动初始化入口(setup 闭包调用)。
|
||||||
/// 初始化顺序即依赖顺序:日志 → 数据目录 → 各管理器 → 托盘 → 进程监控 → 自动启动。
|
/// 初始化顺序即依赖顺序:日志 → 数据目录 → 各管理器 → 托盘 → 进程监控 → 自动启动。
|
||||||
@@ -53,6 +56,22 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let monitor = MonitorKernel::new(app_data_dir.clone());
|
let monitor = MonitorKernel::new(app_data_dir.clone());
|
||||||
app.manage(monitor);
|
app.manage(monitor);
|
||||||
|
|
||||||
|
// ===== 音乐模块:MusicManager(Python 运行时 + 桥接进程) =====
|
||||||
|
// 仅注册状态,不主动启动桥接(由前端模块激活/首次请求时按需拉起)
|
||||||
|
let music = MusicManager::new(app_data_dir.clone());
|
||||||
|
// 注入 AppHandle:桥接 reader 线程把下载事件行转发给前端
|
||||||
|
music.set_app(app.handle().clone());
|
||||||
|
app.manage(music);
|
||||||
|
|
||||||
|
// ===== 翻译模块:TranslateManager =====
|
||||||
|
// 仅注册状态:设置与密钥都在命令调用时按需读取,启动阶段不发任何网络请求
|
||||||
|
// (模型可用性校验因此改为懒校验:首次翻译失败时解释原因 + 设置页手动拉取模型列表)。
|
||||||
|
let translate = TranslateManager::new(app_data_dir.clone());
|
||||||
|
app.manage(translate);
|
||||||
|
// 按设置注册「翻译取词 / 翻译剪贴板」两个全局快捷键并预创建悬浮窗。
|
||||||
|
// 失败只记日志(快捷键被占用不该阻断启动)。
|
||||||
|
crate::translate::init_on_launch(app.handle());
|
||||||
|
|
||||||
// 网速采样不依赖提权,应用启动即开始
|
// 网速采样不依赖提权,应用启动即开始
|
||||||
let network_monitor = Arc::new(NetworkMonitor::new());
|
let network_monitor = Arc::new(NetworkMonitor::new());
|
||||||
app.manage(network_monitor.clone());
|
app.manage(network_monitor.clone());
|
||||||
@@ -66,8 +85,9 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let server_engine = engine.clone();
|
let server_engine = engine.clone();
|
||||||
let server_port = settings.extension_port;
|
let server_port = settings.extension_port;
|
||||||
let server_secret = settings.extension_secret.clone();
|
let server_secret = settings.extension_secret.clone();
|
||||||
|
let server_app = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
ExtensionServer::start(server_engine, server_port, server_secret).await;
|
ExtensionServer::start(server_engine, server_port, server_secret, server_app).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 剪贴板模块:监听 + 快捷键 + 预创建弹窗 =====
|
// ===== 剪贴板模块:监听 + 快捷键 + 预创建弹窗 =====
|
||||||
@@ -87,11 +107,15 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
|
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
|
||||||
crate::clipboard::popup::ensure_popup_window(&app_handle);
|
crate::clipboard::popup::ensure_popup_window(&app_handle);
|
||||||
|
// 同时预创建独立预览窗口(隐藏),弹窗悬停条目时直接显示
|
||||||
|
crate::clipboard::popup::ensure_preview_window(&app_handle);
|
||||||
}
|
}
|
||||||
app.manage(clipboard);
|
app.manage(clipboard);
|
||||||
|
|
||||||
// ===== 快速面板:快捷键 + 预创建弹窗 + 文件索引 =====
|
// ===== 快速面板:快捷键 + 预创建弹窗 =====
|
||||||
// defaultEnabled:true 假设启用;用户在设置页禁用模块时由前端 onDisable 钩子注销快捷键。
|
// defaultEnabled:true 假设启用;用户在设置页禁用模块时由前端 onDisable 钩子注销快捷键。
|
||||||
|
// 文件索引 DB 连接改为懒加载(首次搜索/构建时由 commands 中的 ensure_initialized 触发),
|
||||||
|
// 避免应用启动时即打开 SQLite 连接,降低启动 IO 开销。
|
||||||
let qp_settings = crate::quickpanel::load_settings(&app.handle());
|
let qp_settings = crate::quickpanel::load_settings(&app.handle());
|
||||||
if !qp_settings.shortcut.trim().is_empty() {
|
if !qp_settings.shortcut.trim().is_empty() {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -106,8 +130,6 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
|
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
|
||||||
crate::quickpanel::ensure_window(&app_handle);
|
crate::quickpanel::ensure_window(&app_handle);
|
||||||
}
|
}
|
||||||
// 初始化文件索引数据库(不立即构建,由前端设置页或首次唤起时触发)
|
|
||||||
crate::quickpanel::file_index::init(&app.handle());
|
|
||||||
|
|
||||||
// ===== 托盘菜单 =====
|
// ===== 托盘菜单 =====
|
||||||
crate::tray_menu::create_tray_menu(app.handle())?;
|
crate::tray_menu::create_tray_menu(app.handle())?;
|
||||||
@@ -115,6 +137,9 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// ===== 进程监控线程 =====
|
// ===== 进程监控线程 =====
|
||||||
start_monitoring_thread(app.handle().clone());
|
start_monitoring_thread(app.handle().clone());
|
||||||
|
|
||||||
|
// ===== 代理:自动切换节点后台调度(独立于模块激活状态) =====
|
||||||
|
crate::mihomo_manager::start_auto_switch_loop(app.handle().clone());
|
||||||
|
|
||||||
// ===== 自动启动(随应用启动,不依赖模块启用) =====
|
// ===== 自动启动(随应用启动,不依赖模块启用) =====
|
||||||
// mihomo:用户在设置中开启"自动启动"时随应用启动
|
// mihomo:用户在设置中开启"自动启动"时随应用启动
|
||||||
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||||
@@ -123,16 +148,9 @@ pub fn init(app: &mut App<Wry>) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitor Kernel:硬件监控默认启用,被动读取无副作用
|
// monitor Kernel:用户在设置中开启"自动启动"时随应用启动
|
||||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||||
let monitor = monitor.inner().clone();
|
monitor.auto_start_on_launch(app.handle());
|
||||||
let app_handle = app.handle().clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
match monitor.start_with_subscription(&app_handle).await {
|
|
||||||
Ok(info) => crate::logger::log_info("monitor", &format!("自动启动成功, pid={:?}", info.pid)),
|
|
||||||
Err(e) => crate::logger::log_warn("monitor", &format!("自动启动跳过: {}", e)),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 截图快捷键由前端 screenshotStore 启动时调用 screenshot_register_shortcut 注册
|
// 截图快捷键由前端 screenshotStore 启动时调用 screenshot_register_shortcut 注册
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
//! 取词:智能路径(UIA 直读)优先,兼容路径(模拟 Ctrl+C)兜底。
|
||||||
|
//!
|
||||||
|
//! 整个流程在**调用方的阻塞线程**上执行(最长约 1s)。**严禁在主线程调用**:
|
||||||
|
//! 全局快捷键回调与 UI 线程都不该被这段等待卡住。命令层用 `spawn_blocking` 包装,
|
||||||
|
//! 快捷键回调自行 `std::thread::spawn`。
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use specta::Type;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::clipboard::reader::{read_clipboard, write_dib, write_files, write_text, ClipData};
|
||||||
|
use crate::clipboard::ClipboardManager;
|
||||||
|
use crate::translate::engines::{ErrorKind, TranslateError};
|
||||||
|
use windows_sys::Win32::Foundation::CloseHandle;
|
||||||
|
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
|
||||||
|
use windows_sys::Win32::System::Threading::{
|
||||||
|
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
|
GetAsyncKeyState, SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, VK_CONTROL,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetForegroundWindow, GetWindowThreadProcessId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 模拟按键后等待剪贴板更新的上限。
|
||||||
|
///
|
||||||
|
/// 300ms 偏短:浏览器、Electron 应用、带插件的编辑器在复制前还要走一遍自己的
|
||||||
|
/// 命令分发,慢一点的直接超时。放宽到 600ms 后失败率明显下降,而用户感知的
|
||||||
|
/// 「按下到弹出」延迟仍在可接受范围(取词是主动行为,不是输入反馈)。
|
||||||
|
const PASTE_WAIT: Duration = Duration::from_millis(600);
|
||||||
|
/// 轮询间隔
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||||
|
/// 等用户自然松开修饰键的时间(见 [`neutralise_modifiers`])
|
||||||
|
const MOD_RELEASE_WAIT: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
|
const VK_C: u16 = 0x43;
|
||||||
|
const VK_V: u16 = 0x56;
|
||||||
|
const VK_INSERT: u16 = 0x2D;
|
||||||
|
const VK_MENU: u16 = 0x12; // Alt
|
||||||
|
const VK_SHIFT: u16 = 0x10;
|
||||||
|
const VK_LWIN: u16 = 0x5B;
|
||||||
|
const VK_RWIN: u16 = 0x5C;
|
||||||
|
|
||||||
|
/// 会「污染」Ctrl+C 的修饰键:按下时目标应用看到的是 Alt+Ctrl+C 之类,
|
||||||
|
/// 没有任何应用把它当复制。Alt 与 Shift 会被强制松开(见 [`neutralise_modifiers`])。
|
||||||
|
const BLOCKING_MODIFIERS: [u16; 2] = [VK_MENU, VK_SHIFT];
|
||||||
|
/// 参与「等自然松开」但不强制合成的键:合成 Win 抬起会触发开始菜单,代价太大。
|
||||||
|
const WAIT_ONLY_MODIFIERS: [u16; 2] = [VK_LWIN, VK_RWIN];
|
||||||
|
|
||||||
|
/// 取词参数(来自设置)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CaptureRequest {
|
||||||
|
/// 文本字符数上限,超出直接拒绝而不是发一个巨大的请求
|
||||||
|
pub max_chars: usize,
|
||||||
|
/// 取词后是否还原剪贴板
|
||||||
|
pub restore_clipboard: bool,
|
||||||
|
/// 跳过取词的进程名黑名单(终端类)
|
||||||
|
pub blacklist: Vec<String>,
|
||||||
|
/// 取词方式:"smart"(UIA 直读优先,失败退回模拟按键)| "compat"(只用模拟按键)
|
||||||
|
pub mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取词结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CaptureOutcome {
|
||||||
|
pub text: String,
|
||||||
|
/// 取词来源:"clipboard"(模拟 Ctrl+C)
|
||||||
|
pub source: String,
|
||||||
|
/// 取词时的前台窗口句柄(P3 回填替换选区时要用,事后无法补齐)
|
||||||
|
pub source_hwnd: i64,
|
||||||
|
/// 前台窗口所属进程名(用于提示与黑名单判定)
|
||||||
|
pub source_process: String,
|
||||||
|
/// 剪贴板是否被成功还原。false 有两种情况:备份时剪贴板是**不支持的格式**
|
||||||
|
/// (如仅含 HTML/RTF,无法原样写回),或还原本身失败——此时选区文本会留在剪贴板上。
|
||||||
|
pub restored_clipboard: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行一次取词。**阻塞**,见模块头注释。
|
||||||
|
///
|
||||||
|
/// 并发互斥:连按快捷键(以为没反应再按一次是常见操作)会让两套
|
||||||
|
/// 「备份 → Ctrl+C → 轮询 → 还原」并发执行,互相覆盖剪贴板状态。
|
||||||
|
/// 在途时后来者直接放弃,静默返回。
|
||||||
|
pub fn capture_selection(
|
||||||
|
app: &AppHandle,
|
||||||
|
req: &CaptureRequest,
|
||||||
|
) -> Result<CaptureOutcome, TranslateError> {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||||
|
if IN_FLIGHT.swap(true, Ordering::SeqCst) {
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
"上一次取词仍在进行中",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let result = capture_selection_inner(app, req);
|
||||||
|
IN_FLIGHT.store(false, Ordering::SeqCst);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_selection_inner(
|
||||||
|
app: &AppHandle,
|
||||||
|
req: &CaptureRequest,
|
||||||
|
) -> Result<CaptureOutcome, TranslateError> {
|
||||||
|
let (hwnd, process) = foreground_window_process();
|
||||||
|
let process_name = process.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
if !process_name.is_empty()
|
||||||
|
&& req
|
||||||
|
.blacklist
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.trim().eq_ignore_ascii_case(&process_name))
|
||||||
|
{
|
||||||
|
return Err(TranslateError::unsupported(format!(
|
||||||
|
"「{process_name}」中 Ctrl+C 是中断信号,无法用于取词。\
|
||||||
|
请选中文字后复制,再用「翻译剪贴板」快捷键。"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 智能路径:UIA 直读(不动键盘、不碰剪贴板)=====
|
||||||
|
// 命中即返回:既避免了修饰键残留 / 提权窗口 / 剪贴板占用这三类兼容路径问题,
|
||||||
|
// 也快得多(一次跨进程 COM 调用 vs 一次 600ms 的剪贴板等待)。
|
||||||
|
if req.mode.trim() != "compat" {
|
||||||
|
if let Some(text) = uia_selection() {
|
||||||
|
crate::logger::log_info(
|
||||||
|
"translate",
|
||||||
|
&format!(
|
||||||
|
"取词:UIA 直读命中({} 字符),前台进程 {:?}",
|
||||||
|
text.chars().count(),
|
||||||
|
process_name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return finish_text(req, text, hwnd, &process_name, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏蔽窗口覆盖「Ctrl+C 覆盖剪贴板 → 读走 → 还原」的全过程。
|
||||||
|
// 守卫析构时自动解除,提前 return 也不会漏。
|
||||||
|
let suppress = app.try_state::<ClipboardManager>().map(|m| m.suppress());
|
||||||
|
let _guard = suppress.as_ref().map(|s| s.burst());
|
||||||
|
|
||||||
|
let backup = read_clipboard();
|
||||||
|
let before = clipboard_seq();
|
||||||
|
|
||||||
|
// 关键:先让修饰键回到「都没按」的状态,再发 Ctrl+C。
|
||||||
|
// 全局快捷键是在**按下**的瞬间触发的,此刻 Alt 必然还被物理按住;
|
||||||
|
// 直接补发 Ctrl+C,目标应用收到的是 Alt+Ctrl+C —— 复制不会发生,
|
||||||
|
// 于是必然走到下面的「取词超时」。这是兼容路径取不到词最主要的原因。
|
||||||
|
let forced = neutralise_modifiers();
|
||||||
|
|
||||||
|
send_ctrl_c();
|
||||||
|
let mut updated = wait_clipboard_change(before, PASTE_WAIT);
|
||||||
|
// 重试一次:部分应用首次按键被自身的输入法/菜单状态吃掉,第二次才真正复制。
|
||||||
|
// 复制是幂等的,多按一次没有副作用,比直接判定失败划算。
|
||||||
|
if !updated {
|
||||||
|
send_ctrl_c();
|
||||||
|
updated = wait_clipboard_change(before, PASTE_WAIT / 2);
|
||||||
|
}
|
||||||
|
// Ctrl+Insert 兜底:个别应用对合成的 Ctrl+C 不响应,但认经典的复制和弦
|
||||||
|
// (控制台/部分老程序对 Ctrl+Insert 的处理路径也与 Ctrl+C 不同)。
|
||||||
|
// 无选区时该组合键无副作用,与「复制是幂等的」同理。
|
||||||
|
if !updated {
|
||||||
|
crate::logger::log_info(
|
||||||
|
"translate",
|
||||||
|
&format!("取词:Ctrl+C 未更新剪贴板(前台进程 {process_name:?}),改试 Ctrl+Insert"),
|
||||||
|
);
|
||||||
|
send_ctrl_insert();
|
||||||
|
updated = wait_clipboard_change(before, PASTE_WAIT / 2);
|
||||||
|
}
|
||||||
|
let clip = if updated { read_clipboard() } else { None };
|
||||||
|
// 键盘状态尽早复原:越早把 Alt 按回去,越不容易让目标应用进入菜单栏模式
|
||||||
|
restore_modifiers(&forced);
|
||||||
|
|
||||||
|
if !updated {
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
"取词失败:模拟 Ctrl+C 后剪贴板没有更新。\
|
||||||
|
常见原因是目标窗口以管理员权限运行(系统会丢弃来自普通权限程序的模拟按键),\
|
||||||
|
或该应用不支持复制选区。可改用「翻译剪贴板」:先复制,再按对应快捷键。"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剪贴板已被目标应用覆盖为选区内容。**先还原再做一切判定**:
|
||||||
|
// 还原必须覆盖所有后续路径(非文本 / 空文本 / 超限 / 成功),
|
||||||
|
// 否则「取词失败」的代价是用户剪贴板被悄悄换掉,与 restore_clipboard 设置矛盾。
|
||||||
|
let restored = if req.restore_clipboard {
|
||||||
|
restore_clipboard(backup)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// 还原本身也改了剪贴板。虽然还在屏蔽窗口内,但窗口解除后监听可能才轮到这一次变化,
|
||||||
|
// 于是额外精确记账一次,把边界情况的漏网也堵上。
|
||||||
|
if restored {
|
||||||
|
if let Some(s) = suppress.as_ref() {
|
||||||
|
s.mark_seq(clipboard_seq());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = match clip {
|
||||||
|
Some(ClipData::Text(t)) => t,
|
||||||
|
Some(_) => {
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
"取到的内容不是文本(选区可能是图片或文件)",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
"未获取到选中文本:目标窗口可能不允许复制,或当前没有选中任何文字",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
finish_text(
|
||||||
|
req,
|
||||||
|
text,
|
||||||
|
hwnd,
|
||||||
|
&process_name,
|
||||||
|
// UIA 路径:剪贴板从头到尾没被碰过
|
||||||
|
restored,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取到文本后的公共收尾:裁剪 → 空判定 → 字数上限 → 组装结果。
|
||||||
|
///
|
||||||
|
/// 两条取词路径(UIA 直读 / 模拟 Ctrl+C)都必须过这套校验,否则「字数上限」
|
||||||
|
/// 只对其中一条生效——那正是配置里写「上限」却仍被绕过的原因。
|
||||||
|
fn finish_text(
|
||||||
|
req: &CaptureRequest,
|
||||||
|
text: String,
|
||||||
|
hwnd: i64,
|
||||||
|
process_name: &str,
|
||||||
|
clip_intact: bool,
|
||||||
|
) -> Result<CaptureOutcome, TranslateError> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
"未获取到选中文本(取到的内容为空)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let char_count = trimmed.chars().count();
|
||||||
|
if req.max_chars > 0 && char_count > req.max_chars {
|
||||||
|
return Err(TranslateError::unsupported(format!(
|
||||||
|
"选中内容 {char_count} 字符,超过取词上限({})。\
|
||||||
|
请在「翻译 → 设置 → 划词翻译」中调高上限,或改用主面板翻译。",
|
||||||
|
req.max_chars
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CaptureOutcome {
|
||||||
|
text: trimmed.to_string(),
|
||||||
|
source: "selection".to_string(),
|
||||||
|
source_hwnd: hwnd,
|
||||||
|
source_process: process_name.to_string(),
|
||||||
|
restored_clipboard: clip_intact,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clipboard_seq() -> u32 {
|
||||||
|
unsafe { GetClipboardSequenceNumber() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UIA 直读选区(智能路径)。`windows` crate 只在 Windows 目标上参与构建,
|
||||||
|
/// 因此非 Windows 目标这里直接返回 None(等价于「读不到」→ 走兼容路径)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn uia_selection() -> Option<String> {
|
||||||
|
super::uia_capture::read_selection()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn uia_selection() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等剪贴板序号变化(即目标应用完成了复制)。
|
||||||
|
fn wait_clipboard_change(before: u32, timeout: Duration) -> bool {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if clipboard_seq() != before {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_down(vk: u16) -> bool {
|
||||||
|
(unsafe { GetAsyncKeyState(vk as i32) } as u16 & 0x8000) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送单个按键事件(`up` 为真表示抬起)。
|
||||||
|
fn send_key(vk: u16, up: bool) {
|
||||||
|
let mut input: INPUT = unsafe { std::mem::zeroed() };
|
||||||
|
input.r#type = INPUT_KEYBOARD;
|
||||||
|
input.Anonymous.ki = KEYBDINPUT {
|
||||||
|
wVk: vk,
|
||||||
|
wScan: 0,
|
||||||
|
dwFlags: if up { KEYEVENTF_KEYUP } else { 0 },
|
||||||
|
time: 0,
|
||||||
|
dwExtraInfo: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
SendInput(1, &input, std::mem::size_of::<INPUT>() as i32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 让修饰键回到「都没按」的状态,返回被**强制**松开的键(调用方负责按回去)。
|
||||||
|
///
|
||||||
|
/// 两步走,顺序很重要:
|
||||||
|
/// 1. **先等用户自然松开**。全局快捷键在按键**按下**的瞬间触发,此刻 Alt 一定还按着;
|
||||||
|
/// 绝大多数情况用户几十毫秒内就松手了,等一下既解决了问题,又完全不用合成按键
|
||||||
|
/// (合成 Alt 抬起有让目标应用进入菜单栏模式的风险)。
|
||||||
|
/// 2. 超时仍未松开(长按、卡键)才合成抬起事件。只处理 Alt/Shift:合成 Win 抬起
|
||||||
|
/// 会触发开始菜单,代价远大于收益——Win 参与的组合键本就罕见。
|
||||||
|
fn neutralise_modifiers() -> Vec<u16> {
|
||||||
|
let deadline = Instant::now() + MOD_RELEASE_WAIT;
|
||||||
|
let all: Vec<u16> = BLOCKING_MODIFIERS
|
||||||
|
.iter()
|
||||||
|
.chain(WAIT_ONLY_MODIFIERS.iter())
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if !all.iter().copied().any(key_down) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut forced = Vec::new();
|
||||||
|
for vk in BLOCKING_MODIFIERS {
|
||||||
|
if key_down(vk) {
|
||||||
|
send_key(vk, true);
|
||||||
|
forced.push(vk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !forced.is_empty() {
|
||||||
|
// 给目标应用一点时间处理抬起事件,避免紧接着的 Ctrl+C 被合并成 Alt+Ctrl+C
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
forced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 [`neutralise_modifiers`] 强制松开的键按回去,让用户自己松手时状态一致。
|
||||||
|
///
|
||||||
|
/// **只还原仍然物理按住的键**:用户可能在等待期间就松手了,此时再合成一个 keydown
|
||||||
|
/// 会把 Alt 留在「按下」状态——他下一次敲任意键都会变成 Alt+某键,比不还原糟糕得多。
|
||||||
|
fn restore_modifiers(keys: &[u16]) {
|
||||||
|
for vk in keys {
|
||||||
|
if key_down(*vk) {
|
||||||
|
send_key(*vk, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把备份内容原样写回。仅支持文本 / 图片 / 文件三种格式——其余格式(HTML/RTF 等)
|
||||||
|
/// 在备份阶段就读不出来,因此无法还原,返回 false 而不是假装成功。
|
||||||
|
fn restore_clipboard(backup: Option<ClipData>) -> bool {
|
||||||
|
match backup {
|
||||||
|
Some(ClipData::Text(t)) => write_text(&t),
|
||||||
|
Some(ClipData::Image { dib, .. }) => write_dib(&dib),
|
||||||
|
Some(ClipData::Files(files)) => write_files(&files),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟 Ctrl+V(译文回填替换选区用)。
|
||||||
|
///
|
||||||
|
/// 与 [`send_ctrl_c`] 相同的修饰键处理:若用户仍按着 Ctrl,只补发 V 键,
|
||||||
|
/// 避免把用户的修饰键一并释放。
|
||||||
|
pub fn send_ctrl_v() {
|
||||||
|
let ctrl_held = (unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16 & 0x8000) != 0;
|
||||||
|
|
||||||
|
let mut inputs: Vec<INPUT> = Vec::with_capacity(4);
|
||||||
|
let mut push = |vk: u16, up: bool| {
|
||||||
|
let mut input: INPUT = unsafe { std::mem::zeroed() };
|
||||||
|
input.r#type = INPUT_KEYBOARD;
|
||||||
|
input.Anonymous.ki = KEYBDINPUT {
|
||||||
|
wVk: vk,
|
||||||
|
wScan: 0,
|
||||||
|
dwFlags: if up { KEYEVENTF_KEYUP } else { 0 },
|
||||||
|
time: 0,
|
||||||
|
dwExtraInfo: 0,
|
||||||
|
};
|
||||||
|
inputs.push(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取词悬浮窗是非激活的,前台窗口仍是原应用:Ctrl+V 会落在原选区上
|
||||||
|
let own_ctrl = !ctrl_held;
|
||||||
|
if own_ctrl {
|
||||||
|
push(VK_CONTROL, false);
|
||||||
|
}
|
||||||
|
push(VK_V, false);
|
||||||
|
push(VK_V, true);
|
||||||
|
if own_ctrl {
|
||||||
|
push(VK_CONTROL, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
SendInput(
|
||||||
|
inputs.len() as u32,
|
||||||
|
inputs.as_ptr(),
|
||||||
|
std::mem::size_of::<INPUT>() as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取前台窗口句柄与所属进程名。
|
||||||
|
fn foreground_window_process() -> (i64, Option<String>) {
|
||||||
|
unsafe {
|
||||||
|
let hwnd = GetForegroundWindow();
|
||||||
|
if hwnd == 0 {
|
||||||
|
return (0, None);
|
||||||
|
}
|
||||||
|
let mut pid: u32 = 0;
|
||||||
|
GetWindowThreadProcessId(hwnd, &mut pid);
|
||||||
|
let hwnd_i64 = hwnd as i64;
|
||||||
|
if pid == 0 {
|
||||||
|
return (hwnd_i64, None);
|
||||||
|
}
|
||||||
|
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||||
|
if handle == 0 {
|
||||||
|
// 目标进程权限更高时连句柄都拿不到——这本身就是一个有效信号,
|
||||||
|
// 但不必在这里下结论,让后续「取词超时」去提示
|
||||||
|
return (hwnd_i64, None);
|
||||||
|
}
|
||||||
|
let mut buf = [0u16; 512];
|
||||||
|
let mut size = buf.len() as u32;
|
||||||
|
let ok = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
|
||||||
|
CloseHandle(handle);
|
||||||
|
if ok == 0 {
|
||||||
|
return (hwnd_i64, None);
|
||||||
|
}
|
||||||
|
let path = String::from_utf16_lossy(&buf[..size as usize]);
|
||||||
|
let name = path
|
||||||
|
.rsplit(['\\', '/'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(path.as_str())
|
||||||
|
.to_string();
|
||||||
|
(hwnd_i64, if name.is_empty() { None } else { Some(name) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟「Ctrl + 某键」。
|
||||||
|
///
|
||||||
|
/// 关键细节:若用户此刻**正按着 Ctrl**(例如取词快捷键本身带 Ctrl),
|
||||||
|
/// 我们发出的 Ctrl 抬起会把用户的修饰键一并释放,造成「按一次快捷键后
|
||||||
|
/// Ctrl 行为异常」。因此先探测 Ctrl 的物理状态,只补发缺失的那一段。
|
||||||
|
fn send_ctrl_chord(vk: u16) {
|
||||||
|
// 高位为 1 表示当前处于按下状态
|
||||||
|
let ctrl_held = (unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16 & 0x8000) != 0;
|
||||||
|
|
||||||
|
let mut inputs: Vec<INPUT> = Vec::with_capacity(4);
|
||||||
|
let mut push = |vk: u16, up: bool| {
|
||||||
|
let mut input: INPUT = unsafe { std::mem::zeroed() };
|
||||||
|
input.r#type = INPUT_KEYBOARD;
|
||||||
|
input.Anonymous.ki = KEYBDINPUT {
|
||||||
|
wVk: vk,
|
||||||
|
wScan: 0,
|
||||||
|
dwFlags: if up { KEYEVENTF_KEYUP } else { 0 },
|
||||||
|
time: 0,
|
||||||
|
dwExtraInfo: 0,
|
||||||
|
};
|
||||||
|
inputs.push(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
let own_ctrl = !ctrl_held;
|
||||||
|
if own_ctrl {
|
||||||
|
push(VK_CONTROL, false);
|
||||||
|
}
|
||||||
|
push(vk, false);
|
||||||
|
push(vk, true);
|
||||||
|
if own_ctrl {
|
||||||
|
push(VK_CONTROL, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
SendInput(
|
||||||
|
inputs.len() as u32,
|
||||||
|
inputs.as_ptr(),
|
||||||
|
std::mem::size_of::<INPUT>() as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟 Ctrl+C。
|
||||||
|
fn send_ctrl_c() {
|
||||||
|
send_ctrl_chord(VK_C);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟 Ctrl+Insert:部分应用对合成的 Ctrl+C 不响应,但认这条经典复制和弦。
|
||||||
|
fn send_ctrl_insert() {
|
||||||
|
send_ctrl_chord(VK_INSERT);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! 取词:把「用户选中的文本」弄到手。
|
||||||
|
//!
|
||||||
|
//! 两条路径,由 `selection.mode` 决定(默认 `"smart"`):
|
||||||
|
//! - **UIA 直读**([`uia_capture`]):向目标进程的自动化提供者要当前选区。
|
||||||
|
//! 不模拟按键、不碰剪贴板,因此不受修饰键残留、UIPI、剪贴板占用影响。读不到就
|
||||||
|
//! 返回 None,自动退回下一条路径。
|
||||||
|
//! - **兼容路径**([`clipboard_capture`]):备份剪贴板 → 模拟 Ctrl+C → 读走 → 还原。
|
||||||
|
//! 覆盖最广(几乎所有支持复制的宿主都行),代价是短暂占用剪贴板。
|
||||||
|
//!
|
||||||
|
//! 调用方不该关心用了哪条路径,只看 [`clipboard_capture::CaptureOutcome::source`] 即可。
|
||||||
|
//!
|
||||||
|
//! 四条必须显式处理的现实约束(都是踩过才知道的):
|
||||||
|
//! 0. **修饰键残留**:全局快捷键在按键**按下**瞬间触发,此时 Alt 仍被物理按住,
|
||||||
|
//! 补发的 Ctrl+C 在目标应用看来是 `Alt+Ctrl+C` —— 没有任何应用把它当复制。
|
||||||
|
//! 这是「按了快捷键却取不到词」最主要的原因,见
|
||||||
|
//! [`clipboard_capture`] 里的 `neutralise_modifiers`。
|
||||||
|
//! 1. **剪贴板必须被屏蔽**:整个取词过程会动三次剪贴板,而监听线程是 250ms 轮询,
|
||||||
|
//! 按序列号逐个记账存在竞态。见 [`crate::clipboard::suppress`]。
|
||||||
|
//! 2. **终端类应用不能取词**:Ctrl+C 在那里是中断信号。走进程黑名单,给出可行的替代做法。
|
||||||
|
//! 3. **提权窗口取不到词**:目标进程以管理员权限运行时,非提权进程的 `SendInput`
|
||||||
|
//! 会被 UIPI 直接丢弃(不报错、无反馈)。因此必须区分「超时」与「剪贴板变了但没有文本」,
|
||||||
|
//! 否则用户只会看到一句含糊的失败。
|
||||||
|
|
||||||
|
pub mod clipboard_capture;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod uia_capture;
|
||||||
|
|
||||||
|
pub use clipboard_capture::{capture_selection, send_ctrl_v, CaptureRequest};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! UIA 直读取词:不模拟任何按键,直接从焦点元素读出选区文本。
|
||||||
|
//!
|
||||||
|
//! 为什么需要它 —— 模拟 Ctrl+C 这条兼容路径有三个绕不过去的现实问题:
|
||||||
|
//! 1. **修饰键残留**。全局快捷键在**按键按下**的瞬间触发,此时 Alt 仍被物理按住,
|
||||||
|
//! 我们补发的 Ctrl+C 在目标应用看来是 `Alt+Ctrl+C` —— 没有任何应用把它当「复制」,
|
||||||
|
//! 于是必然走到「取词超时」。这正是「试了好几个程序都取不到」的主因。
|
||||||
|
//! 2. **提权窗口**。UIPI 会静默丢弃来自低完整性级别进程的模拟按键。
|
||||||
|
//! 3. **剪贴板占用**。取词期间用户的剪贴板被临时换掉,任何并发的复制都会丢。
|
||||||
|
//!
|
||||||
|
//! UIA 三条全避开:它只是「问」目标进程的自动化提供者要当前选区,不改键盘状态、
|
||||||
|
//! 不碰剪贴板。浏览器(Chromium / Firefox)、Office、多数 Qt / Win32 编辑控件都提供
|
||||||
|
//! TextPattern;读不到就返回 `None`,由调用方退回兼容路径。
|
||||||
|
//!
|
||||||
|
//! 两条工程约束:
|
||||||
|
//! - **必须在独立线程上执行并带超时**。UIA 是跨进程 COM 调用,目标进程无响应时
|
||||||
|
//! `GetFocusedElement` 会一直挂着(既不返回也不报错)。没有超时就会漏线程、
|
||||||
|
//! 并且让「取词」这个动作永久卡住。
|
||||||
|
//! - **只能尽力而为**。这里不返回 `Result`:UIA 读不到是正常的(很多程序没有
|
||||||
|
//! TextPattern),调用方只需按「有 / 没有」分支,不需要错误文案。
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::mpsc as std_mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use windows::Win32::System::Com::{
|
||||||
|
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_SERVER, COINIT_MULTITHREADED,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::Accessibility::{
|
||||||
|
CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTextPattern,
|
||||||
|
IUIAutomationTextRangeArray, UIA_TextPatternId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 单次 UIA 读取的超时上限。
|
||||||
|
///
|
||||||
|
/// 700ms 的依据:本地跨进程 COM 往返正常在 10ms 量级;给到 700ms 足以覆盖目标进程
|
||||||
|
/// 偶发忙碌,又不会让用户感到「按了没反应」。再长就该交给兼容路径去兜底了。
|
||||||
|
const UIA_TIMEOUT: Duration = Duration::from_millis(700);
|
||||||
|
|
||||||
|
/// 沿焦点元素向上找 TextPattern 的最大层数。
|
||||||
|
///
|
||||||
|
/// 浏览器里焦点常落在一个深层节点(甚至是 body),而 TextPattern 挂在更上层的
|
||||||
|
/// 文档节点上;但要限制层数——一路上溯到桌面根节点既慢又可能读到整页文本。
|
||||||
|
const MAX_ANCESTORS: usize = 4;
|
||||||
|
|
||||||
|
/// 连续超时次数上限。达到后本次进程内暂停尝试 UIA。
|
||||||
|
///
|
||||||
|
/// 超时意味着目标进程(或 UIA 服务)无响应。每次取词都起一个注定挂住的线程
|
||||||
|
/// 会持续泄漏,因此给它一个熔断。熔断带 60s 衰减(见 [`read_selection`]):
|
||||||
|
/// 只针对当下无响应的目标,不该让一次抖动永久禁用 UIA。
|
||||||
|
static TIMEOUTS: AtomicU32 = AtomicU32::new(0);
|
||||||
|
const TIMEOUT_LIMIT: u32 = 3;
|
||||||
|
/// 最近一次 UIA 超时的系统时间(毫秒),配合 TIMEOUTS 做衰减复位
|
||||||
|
static LAST_TIMEOUT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
/// 熔断衰减窗口:距上次超时超过该时长即清零计数,重新给 UIA 机会
|
||||||
|
const TIMEOUT_DECAY_MS: u64 = 60_000;
|
||||||
|
|
||||||
|
fn system_millis() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试用 UIA 直读当前选区文本。读不到返回 `None`。
|
||||||
|
pub fn read_selection() -> Option<String> {
|
||||||
|
// 熔断 + 衰减:达到上限后跳过 UIA,但 60s 内未再超时就清零重新启用。
|
||||||
|
// 没有衰减的话,一次抖动(目标进程短暂忙碌)就会让 UIA 在整个进程生命周期里失效,
|
||||||
|
// 之后所有取词都退到模拟按键路径——症状正是「换个程序也取不到」。
|
||||||
|
if TIMEOUTS.load(Ordering::SeqCst) >= TIMEOUT_LIMIT {
|
||||||
|
let last = LAST_TIMEOUT_MS.load(Ordering::SeqCst);
|
||||||
|
let now = system_millis();
|
||||||
|
if last != 0 && now.saturating_sub(last) < TIMEOUT_DECAY_MS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
TIMEOUTS.store(0, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = std_mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = read_selection_blocking();
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
match rx.recv_timeout(UIA_TIMEOUT) {
|
||||||
|
Ok(Some(text)) => {
|
||||||
|
// 命中即清零熔断计数:超时针对的是「当下的目标」,成功说明 UIA 服务正常
|
||||||
|
TIMEOUTS.store(0, Ordering::SeqCst);
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
// 读不到(没有 TextPattern / 无选区):正常情况,交给兼容路径
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(_) => {
|
||||||
|
LAST_TIMEOUT_MS.store(system_millis(), Ordering::SeqCst);
|
||||||
|
let n = TIMEOUTS.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
crate::logger::log_warn(
|
||||||
|
"translate",
|
||||||
|
&format!("UIA 取词超时(第 {n} 次),本次改用模拟 Ctrl+C 兜底"),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真正的读取逻辑。**阻塞**,且必须在独立线程上调用(见模块头注释)。
|
||||||
|
fn read_selection_blocking() -> Option<String> {
|
||||||
|
unsafe {
|
||||||
|
let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||||
|
// 只在本次调用确实初始化了 COM 时才配平 Uninitialize
|
||||||
|
let uninit = hr.is_ok();
|
||||||
|
let result = read_selection_inner();
|
||||||
|
if uninit {
|
||||||
|
CoUninitialize();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_selection_inner() -> Option<String> {
|
||||||
|
unsafe {
|
||||||
|
let automation: IUIAutomation = CoCreateInstance(&CUIAutomation, None, CLSCTX_SERVER).ok()?;
|
||||||
|
let walker = automation.ControlViewWalker().ok()?;
|
||||||
|
let mut element: Option<IUIAutomationElement> = automation.GetFocusedElement().ok();
|
||||||
|
|
||||||
|
let mut depth = 0;
|
||||||
|
while let Some(el) = element {
|
||||||
|
if let Some(text) = selection_text_of(&el) {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth >= MAX_ANCESTORS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
element = walker.GetParentElement(&el).ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取某个元素上「当前选区」的文本。元素不支持 TextPattern 或没有选区 → None。
|
||||||
|
fn selection_text_of(element: &IUIAutomationElement) -> Option<String> {
|
||||||
|
unsafe {
|
||||||
|
let pattern = element
|
||||||
|
.GetCurrentPatternAs::<IUIAutomationTextPattern>(UIA_TextPatternId)
|
||||||
|
.ok()?;
|
||||||
|
let ranges: IUIAutomationTextRangeArray = pattern.GetSelection().ok()?;
|
||||||
|
join_ranges(&ranges)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把多个选区区间拼成一段文本(`-1` 表示不限长度,取区间全部内容)。
|
||||||
|
fn join_ranges(ranges: &IUIAutomationTextRangeArray) -> Option<String> {
|
||||||
|
unsafe {
|
||||||
|
let count = ranges.Length().ok()?;
|
||||||
|
if count <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for i in 0..count {
|
||||||
|
let range = ranges.GetElement(i).ok()?;
|
||||||
|
let text = range.GetText(-1).ok()?.to_string();
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
parts.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parts.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,593 @@
|
|||||||
|
//! OpenAI 兼容引擎。
|
||||||
|
//!
|
||||||
|
//! 一套代码覆盖 DeepSeek / OpenAI / 通义(DashScope 兼容模式)/ Kimi / 智谱 /
|
||||||
|
//! 本地 Ollama / LM Studio / one-api 等中转服务——它们都提供
|
||||||
|
//! `POST {baseUrl}/chat/completions` 且请求响应结构一致,因此差异只在配置项里。
|
||||||
|
//!
|
||||||
|
//! 翻译场景刻意**不开思考模式**:不写 `thinking` / `reasoning_effort`,换低延迟与低费用。
|
||||||
|
//! 若用户确实需要,可通过 `extra_body`(JSON 文本)自行透传。
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
apply_common_params, build_user_content, render_template, EngineRequest, ErrorKind, TokenUsage,
|
||||||
|
TranslateEngine, TranslateError, TranslateMode, TranslateResult,
|
||||||
|
};
|
||||||
|
use crate::translate::settings::{PromptTemplates, TranslateEngineConfig};
|
||||||
|
|
||||||
|
/// 自检与连通性测试使用的探测文本
|
||||||
|
const PROBE_TEXT: &str = "Hello, world.";
|
||||||
|
|
||||||
|
pub struct AiEngine {
|
||||||
|
cfg: TranslateEngineConfig,
|
||||||
|
templates: PromptTemplates,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AiEngine {
|
||||||
|
pub fn new(
|
||||||
|
cfg: TranslateEngineConfig,
|
||||||
|
templates: PromptTemplates,
|
||||||
|
client: reqwest::Client,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
cfg,
|
||||||
|
templates,
|
||||||
|
client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 根地址。容错处理:用户常把完整端点(`.../chat/completions`)直接粘进来,
|
||||||
|
/// 若不在末尾剥掉,就会拼出 `.../chat/completions/chat/completions`,
|
||||||
|
/// 而这类错误在上游表现为 404,排查成本远高于此处一行判断。
|
||||||
|
fn api_root(&self) -> Result<String, TranslateError> {
|
||||||
|
let raw = self.cfg.base_url.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"引擎「{}」尚未配置 Base URL",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(raw.starts_with("http://") || raw.starts_with("https://")) {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"Base URL 需以 http:// 或 https:// 开头,当前为「{raw}」"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut root = raw.trim_end_matches('/').to_string();
|
||||||
|
for suffix in ["/chat/completions", "/completions", "/models"] {
|
||||||
|
if let Some(stripped) = root.strip_suffix(suffix) {
|
||||||
|
root = stripped.trim_end_matches('/').to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_endpoint(&self) -> Result<String, TranslateError> {
|
||||||
|
Ok(format!("{}/chat/completions", self.api_root()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn models_endpoint(&self) -> Result<String, TranslateError> {
|
||||||
|
Ok(format!("{}/models", self.api_root()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 密钥只从系统凭据管理器读,不进配置文件、不经前端。
|
||||||
|
fn api_key(&self) -> String {
|
||||||
|
crate::translate::engine_api_key(&self.cfg.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_key(&self) -> Result<String, TranslateError> {
|
||||||
|
let key = self.api_key();
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err(TranslateError::auth(format!(
|
||||||
|
"引擎「{}」尚未配置 API Key,请在翻译设置中填写",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_model(&self) -> Result<String, TranslateError> {
|
||||||
|
let model = self.cfg.model.trim();
|
||||||
|
if model.is_empty() {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"引擎「{}」尚未选择模型,可在设置中拉取模型列表后选择",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(model.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有效 system prompt:实例级自定义提示词优先,否则用模式对应的全局模板。
|
||||||
|
fn system_prompt(&self, req: &EngineRequest) -> String {
|
||||||
|
if !self.cfg.system_prompt.trim().is_empty() {
|
||||||
|
return render_template(&self.cfg.system_prompt, req);
|
||||||
|
}
|
||||||
|
render_template(self.templates.for_mode(req.mode), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式收尾。空内容不直接判死:**自动改用同步接口重试一次**。
|
||||||
|
///
|
||||||
|
/// 上游偶发「流正常结束但 content 为空」(安全拦截、思维链吃满 max_tokens、
|
||||||
|
/// 中转服务抖动都会导致)。用户视角这与请求失败无异,但同步接口往往能正常
|
||||||
|
/// 返回——与其抛错让人手动重试,不如在这里自愈一次。重试失败才把错误交给上层。
|
||||||
|
async fn finish_stream_with_fallback(
|
||||||
|
&self,
|
||||||
|
tx: &tokio::sync::mpsc::Sender<super::StreamEvent>,
|
||||||
|
request_id: String,
|
||||||
|
content: String,
|
||||||
|
req: &EngineRequest,
|
||||||
|
started: Instant,
|
||||||
|
finish_reason: Option<String>,
|
||||||
|
) -> Result<(), TranslateError> {
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
if tx.is_closed() {
|
||||||
|
// 调用方已放弃(停止 / 新请求顶替),不再花一次 API 调用
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
crate::logger::log_warn(
|
||||||
|
"translate",
|
||||||
|
&format!(
|
||||||
|
"「{}」流式返回为空(finish_reason={:?}),自动改用同步接口重试",
|
||||||
|
self.cfg.name, finish_reason
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 同步路径的错误更具体(认证/额度/响应解析都能区分),直接透传
|
||||||
|
let result = self.translate(req).await?;
|
||||||
|
let _ = tx.send(super::StreamEvent::Done { request_id, result }).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
finish_stream(
|
||||||
|
tx,
|
||||||
|
request_id,
|
||||||
|
content,
|
||||||
|
req,
|
||||||
|
&self.cfg,
|
||||||
|
started.elapsed().as_millis() as u64,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TranslateEngine for AiEngine {
|
||||||
|
fn config(&self) -> &TranslateEngineConfig {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate(&self, req: &EngineRequest) -> Result<TranslateResult, TranslateError> {
|
||||||
|
if req.text.trim().is_empty() {
|
||||||
|
return Err(TranslateError::empty());
|
||||||
|
}
|
||||||
|
let key = self.require_key()?;
|
||||||
|
let model = self.require_model()?;
|
||||||
|
let endpoint = self.chat_endpoint()?;
|
||||||
|
|
||||||
|
let mut body = serde_json::Map::new();
|
||||||
|
body.insert("model".to_string(), json!(model));
|
||||||
|
body.insert("stream".to_string(), json!(false));
|
||||||
|
body.insert(
|
||||||
|
"messages".to_string(),
|
||||||
|
json!([
|
||||||
|
{ "role": "system", "content": self.system_prompt(req) },
|
||||||
|
{ "role": "user", "content": build_user_content(req, self.cfg.supports_vision) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
apply_common_params(&mut body, &self.cfg);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&endpoint)
|
||||||
|
.bearer_auth(&key)
|
||||||
|
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(1000)))
|
||||||
|
.json(&serde_json::Value::Object(body))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_reqwest(e, &self.cfg.name))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let raw = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name)))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(classify_http(status.as_u16(), &raw, &self.cfg.name, &model));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: ChatResponse = serde_json::from_str(&raw).map_err(|e| {
|
||||||
|
TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name))
|
||||||
|
.with_detail(&raw)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(err) = parsed.error {
|
||||||
|
let msg = err
|
||||||
|
.message
|
||||||
|
.filter(|m| !m.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "上游返回了错误对象".to_string());
|
||||||
|
return Err(
|
||||||
|
TranslateError::new(ErrorKind::Unknown, format!("「{}」返回错误:{msg}", self.cfg.name))
|
||||||
|
.with_detail(&raw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|c| c.message.content)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(TranslateError::empty)?;
|
||||||
|
|
||||||
|
let latency_ms = started.elapsed().as_millis() as u64;
|
||||||
|
let usage = parsed.usage.map(|u| {
|
||||||
|
let total = if u.total_tokens > 0 {
|
||||||
|
u.total_tokens
|
||||||
|
} else {
|
||||||
|
u.prompt_tokens + u.completion_tokens
|
||||||
|
};
|
||||||
|
TokenUsage {
|
||||||
|
prompt_tokens: u.prompt_tokens,
|
||||||
|
completion_tokens: u.completion_tokens,
|
||||||
|
total_tokens: total,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(TranslateResult {
|
||||||
|
text,
|
||||||
|
// AI 引擎不做语言检测:显式指定了源语言时回显,auto 时留空由前端展示「自动」
|
||||||
|
detected: if req.from.trim().is_empty() || req.from == "auto" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(req.from.clone())
|
||||||
|
},
|
||||||
|
engine_id: self.cfg.id.clone(),
|
||||||
|
engine_name: self.cfg.name.clone(),
|
||||||
|
latency_ms,
|
||||||
|
usage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式翻译(SSE)。增量经通道下发,`data: [DONE]` 或流结束时发 Done。
|
||||||
|
///
|
||||||
|
/// 错误处理约定:**本方法不发送 `StreamEvent::Error`**——任何失败都通过 `Err` 返回,
|
||||||
|
/// 由命令层统一转成错误事件,避免前端收到两条错误。通道关闭(调用方已放弃,
|
||||||
|
/// 例如用户点了停止)时安静返回 `Ok(())`,不当作失败。
|
||||||
|
async fn translate_stream(
|
||||||
|
&self,
|
||||||
|
req: &EngineRequest,
|
||||||
|
request_id: String,
|
||||||
|
tx: tokio::sync::mpsc::Sender<super::StreamEvent>,
|
||||||
|
) -> Result<(), TranslateError> {
|
||||||
|
if req.text.trim().is_empty() && req.image_png.is_none() {
|
||||||
|
return Err(TranslateError::empty());
|
||||||
|
}
|
||||||
|
let key = self.require_key()?;
|
||||||
|
let model = self.require_model()?;
|
||||||
|
let endpoint = self.chat_endpoint()?;
|
||||||
|
|
||||||
|
let mut body = serde_json::Map::new();
|
||||||
|
body.insert("model".to_string(), json!(model));
|
||||||
|
body.insert("stream".to_string(), json!(true));
|
||||||
|
body.insert(
|
||||||
|
"messages".to_string(),
|
||||||
|
json!([
|
||||||
|
{ "role": "system", "content": self.system_prompt(req) },
|
||||||
|
{ "role": "user", "content": build_user_content(req, self.cfg.supports_vision) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
apply_common_params(&mut body, &self.cfg);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&endpoint)
|
||||||
|
.bearer_auth(&key)
|
||||||
|
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(1000)))
|
||||||
|
.json(&serde_json::Value::Object(body))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_reqwest(e, &self.cfg.name))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let raw = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(classify_http(status.as_u16(), &raw, &self.cfg.name, &model));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE 逐行解析:字节块可能把一行劈成两半,必须先攒缓冲再按 \n 切
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
let mut buffer: Vec<u8> = Vec::new();
|
||||||
|
let mut content = String::new();
|
||||||
|
let mut finish_reason: Option<String> = None;
|
||||||
|
|
||||||
|
while let Some(item) = stream.next().await {
|
||||||
|
let bytes =
|
||||||
|
item.map_err(|e| TranslateError::network(format!("读取流失败: {e}")))?;
|
||||||
|
buffer.extend_from_slice(&bytes);
|
||||||
|
while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
|
||||||
|
let line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
|
||||||
|
let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len() - 1]);
|
||||||
|
let Some(payload) = line.trim().strip_prefix("data:") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let payload = payload.trim();
|
||||||
|
if payload.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if payload == "[DONE]" {
|
||||||
|
return self
|
||||||
|
.finish_stream_with_fallback(
|
||||||
|
&tx,
|
||||||
|
request_id,
|
||||||
|
content,
|
||||||
|
req,
|
||||||
|
started,
|
||||||
|
finish_reason,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(payload) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// 上游在流中携带错误对象时终止
|
||||||
|
if let Some(message) = value
|
||||||
|
.pointer("/error/message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.filter(|m| !m.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Unknown,
|
||||||
|
format!("「{}」流中返回错误:{message}", self.cfg.name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(fr) = value
|
||||||
|
.pointer("/choices/0/finish_reason")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
finish_reason = Some(fr.to_string());
|
||||||
|
}
|
||||||
|
let Some(delta) = value
|
||||||
|
.pointer("/choices/0/delta/content")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.filter(|d| !d.is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
content.push_str(delta);
|
||||||
|
if tx
|
||||||
|
.send(super::StreamEvent::Chunk {
|
||||||
|
request_id: request_id.clone(),
|
||||||
|
delta: delta.to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// 通道已关 = 调用方放弃(停止 / 新请求顶替),安静退出
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流结束但没收到 [DONE]:部分上游异常断流。已有内容仍视为成功,
|
||||||
|
// 否则用户会看着已译出一半的结果被告知失败。
|
||||||
|
self.finish_stream_with_fallback(
|
||||||
|
&tx,
|
||||||
|
request_id,
|
||||||
|
content,
|
||||||
|
req,
|
||||||
|
started,
|
||||||
|
finish_reason,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<String>, TranslateError> {
|
||||||
|
let key = self.require_key()?;
|
||||||
|
let endpoint = self.models_endpoint()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&endpoint)
|
||||||
|
.bearer_auth(&key)
|
||||||
|
.timeout(Duration::from_millis(self.cfg.timeout_ms.clamp(3_000, 15_000)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_reqwest(e, &self.cfg.name))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let raw = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TranslateError::network(format!("读取「{}」模型列表失败: {e}", self.cfg.name)))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(classify_http(
|
||||||
|
status.as_u16(),
|
||||||
|
&raw,
|
||||||
|
&self.cfg.name,
|
||||||
|
self.cfg.model.as_str(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: ModelsResponse = serde_json::from_str(&raw).map_err(|e| {
|
||||||
|
TranslateError::parse(format!("「{}」的模型列表无法解析: {e}", self.cfg.name))
|
||||||
|
.with_detail(&raw)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = parsed
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| m.id)
|
||||||
|
.filter(|id| !id.trim().is_empty())
|
||||||
|
.collect();
|
||||||
|
ids.sort();
|
||||||
|
ids.dedup();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test(&self) -> Result<String, TranslateError> {
|
||||||
|
let req = EngineRequest {
|
||||||
|
text: PROBE_TEXT.to_string(),
|
||||||
|
from: "en".to_string(),
|
||||||
|
to: "zh-Hans".to_string(),
|
||||||
|
to_label: "简体中文".to_string(),
|
||||||
|
from_label: "英语".to_string(),
|
||||||
|
mode: TranslateMode::Translate,
|
||||||
|
image_png: None,
|
||||||
|
via: "preview".to_string(),
|
||||||
|
};
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = self.translate(&req).await?;
|
||||||
|
let total = started.elapsed().as_millis() as u64;
|
||||||
|
Ok(format!(
|
||||||
|
"连通正常 · 模型 {} · {}ms · 回显「{}」",
|
||||||
|
self.cfg.model, total, result.text
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收尾:把累积内容封装成结果下发。空内容按「上游没产出」处理。
|
||||||
|
async fn finish_stream(
|
||||||
|
tx: &tokio::sync::mpsc::Sender<super::StreamEvent>,
|
||||||
|
request_id: String,
|
||||||
|
content: String,
|
||||||
|
req: &EngineRequest,
|
||||||
|
cfg: &TranslateEngineConfig,
|
||||||
|
latency_ms: u64,
|
||||||
|
) -> Result<(), TranslateError> {
|
||||||
|
let text = content.trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
format!("「{}」未返回任何译文(流提前结束)", cfg.name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = tx
|
||||||
|
.send(super::StreamEvent::Done {
|
||||||
|
request_id,
|
||||||
|
result: TranslateResult {
|
||||||
|
text,
|
||||||
|
detected: if req.from.trim().is_empty() || req.from == "auto" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(req.from.clone())
|
||||||
|
},
|
||||||
|
engine_id: cfg.id.clone(),
|
||||||
|
engine_name: cfg.name.clone(),
|
||||||
|
latency_ms,
|
||||||
|
// 流式路径不索取 usage:stream_options 是各家扩展,兼容性不一
|
||||||
|
usage: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络层错误分类。
|
||||||
|
fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError {
|
||||||
|
if e.is_timeout() {
|
||||||
|
return TranslateError::timeout(format!(
|
||||||
|
"请求「{engine}」超时,可在引擎设置中调大超时时间,或检查网络"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if e.is_connect() {
|
||||||
|
return TranslateError::network(format!(
|
||||||
|
"无法连接「{engine}」:{e}(若该服务在境外,请检查网络或开启代理)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
TranslateError::network(format!("请求「{engine}」失败:{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 状态码分类。把「Key 不对」「模型名不对」「被限流」分开,
|
||||||
|
/// 是因为这三者在前端的处置动作完全不同。
|
||||||
|
fn classify_http(status: u16, body: &str, engine: &str, model: &str) -> TranslateError {
|
||||||
|
let snippet = body.trim();
|
||||||
|
let lower = snippet.to_lowercase();
|
||||||
|
let err = match status {
|
||||||
|
401 | 403 => TranslateError::auth(format!(
|
||||||
|
"「{engine}」认证失败(HTTP {status}),请检查 API Key 是否正确、是否有该模型的权限"
|
||||||
|
)),
|
||||||
|
402 => TranslateError::auth(format!("「{engine}」余额不足或未开通计费(HTTP 402)")),
|
||||||
|
429 => TranslateError::rate_limit(format!(
|
||||||
|
"「{engine}」请求过于频繁(HTTP 429),请稍后重试或降低频率"
|
||||||
|
)),
|
||||||
|
400 | 404 | 422 => {
|
||||||
|
if lower.contains("model") {
|
||||||
|
TranslateError::config(format!(
|
||||||
|
"「{engine}」不识别模型「{model}」(HTTP {status}):上游模型名可能已变更,\
|
||||||
|
请在设置中拉取模型列表后重新选择"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
TranslateError::new(
|
||||||
|
ErrorKind::Config,
|
||||||
|
format!("「{engine}」拒绝了该请求(HTTP {status})"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
408 | 504 => TranslateError::timeout(format!("「{engine}」上游超时(HTTP {status})")),
|
||||||
|
s if (500..600).contains(&s) => {
|
||||||
|
TranslateError::network(format!("「{engine}」服务端错误(HTTP {status}),可稍后重试"))
|
||||||
|
}
|
||||||
|
s => TranslateError::new(ErrorKind::Unknown, format!("「{engine}」返回 HTTP {s}")),
|
||||||
|
};
|
||||||
|
err.with_detail(snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 响应结构(宽松解析:缺字段不报错,由业务层判断内容是否可用) =====
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
choices: Vec<ChatChoice>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<ChatUsage>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<ApiErrorBody>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatChoice {
|
||||||
|
#[serde(default)]
|
||||||
|
message: ChatMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ChatMessage {
|
||||||
|
/// 部分实现会返回 null(例如只产出思维链时),故用 Option 而非 String
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatUsage {
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
completion_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
total_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApiErrorBody {
|
||||||
|
#[serde(default)]
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ModelsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
data: Vec<ModelEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ModelEntry {
|
||||||
|
#[serde(default)]
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//! DeepL 云厂商翻译源。
|
||||||
|
//!
|
||||||
|
//! 选它作云厂商第一家 purely 因为接入成本:`Authorization: DeepL-Auth-Key <key>`
|
||||||
|
//! 一个头就完成鉴权,没有 MD5/TC3 那类签名流程。其它云厂商(百度/腾讯/阿里/有道)
|
||||||
|
//! 签名各不相同,按需再加。
|
||||||
|
//!
|
||||||
|
//! 三个必须如实告知用户的限制:
|
||||||
|
//! - **免费 Key 与付费 Key 的端点不同**(`api-free.deepl.com` / `api.deepl.com`)。
|
||||||
|
//! 用错端点会返回 403,错误信息里必须把这个可能性讲出来,否则用户只会反复重输 Key。
|
||||||
|
//! - **目标语言只有简体中文**(`ZH`)。DeepL 暂无繁体中文目标,本实现会把
|
||||||
|
//! `zh-Hant` 也映射到 `ZH`,译出的会是简体——不是 bug,是上游能力边界。
|
||||||
|
//! - 无流式接口,走 trait 默认实现(同步完成后整体下发)。
|
||||||
|
//!
|
||||||
|
//! HTTP 形态:`POST {base}/translate`,body `{"text":["..."],"target_lang":"ZH"}`,
|
||||||
|
//! `source_lang` 省略时由上游自动检测。
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
is_auto, provider_code, ErrorKind, TranslateEngine, TranslateError, TranslateResult,
|
||||||
|
};
|
||||||
|
use crate::translate::settings::TranslateEngineConfig;
|
||||||
|
|
||||||
|
const PROBE_TEXT: &str = "Hello, world.";
|
||||||
|
|
||||||
|
pub struct DeepLEngine {
|
||||||
|
cfg: TranslateEngineConfig,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeepLEngine {
|
||||||
|
pub fn new(cfg: TranslateEngineConfig, client: reqwest::Client) -> Self {
|
||||||
|
Self { cfg, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 根地址(容错:剥掉误粘的 `/translate`,与 AI 引擎同一思路)
|
||||||
|
fn api_root(&self) -> Result<String, TranslateError> {
|
||||||
|
let raw = self.cfg.base_url.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"引擎「{}」尚未配置 Base URL(免费 Key 用 https://api-free.deepl.com/v2,\
|
||||||
|
付费 Key 用 https://api.deepl.com/v2)",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(raw.starts_with("http://") || raw.starts_with("https://")) {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"Base URL 需以 http:// 或 https:// 开头,当前为「{raw}」"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut root = raw.trim_end_matches('/').to_string();
|
||||||
|
if let Some(stripped) = root.strip_suffix("/translate") {
|
||||||
|
root = stripped.trim_end_matches('/').to_string();
|
||||||
|
}
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_key(&self) -> Result<String, TranslateError> {
|
||||||
|
let key = crate::translate::engine_api_key(&self.cfg.id);
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err(TranslateError::auth(format!(
|
||||||
|
"引擎「{}」尚未配置 DeepL Auth Key",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标语言码。DeepL 要求变体形式:英语必须是 EN-GB/EN-US,葡语必须是 PT-PT/PT-BR。
|
||||||
|
fn target_code(internal: &str) -> Result<String, TranslateError> {
|
||||||
|
let code = provider_code(internal);
|
||||||
|
let lower = code.to_lowercase();
|
||||||
|
let out = match lower.as_str() {
|
||||||
|
"zh" | "zh-cn" => "ZH".to_string(),
|
||||||
|
"zh-tw" | "zh-hant" => "ZH".to_string(),
|
||||||
|
"en" => "EN-US".to_string(),
|
||||||
|
"en-gb" => "EN-GB".to_string(),
|
||||||
|
"en-us" => "EN-US".to_string(),
|
||||||
|
"pt" => "PT-BR".to_string(),
|
||||||
|
"pt-pt" => "PT-PT".to_string(),
|
||||||
|
"pt-br" => "PT-BR".to_string(),
|
||||||
|
other => other.split('-').next().unwrap_or(other).to_uppercase(),
|
||||||
|
};
|
||||||
|
if out.trim().is_empty() {
|
||||||
|
return Err(TranslateError::config("未指定目标语言"));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 源语言码。省略(auto)时不上送,由上游检测。
|
||||||
|
fn source_code(internal: &str) -> Option<String> {
|
||||||
|
if is_auto(internal) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let code = provider_code(internal);
|
||||||
|
Some(code.split('-').next().unwrap_or(&code).to_uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TranslateEngine for DeepLEngine {
|
||||||
|
fn config(&self) -> &TranslateEngineConfig {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate(
|
||||||
|
&self,
|
||||||
|
req: &super::EngineRequest,
|
||||||
|
) -> Result<TranslateResult, TranslateError> {
|
||||||
|
let text = req.text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(TranslateError::empty());
|
||||||
|
}
|
||||||
|
let key = self.require_key()?;
|
||||||
|
let target = Self::target_code(&req.to)?;
|
||||||
|
let root = self.api_root()?;
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({ "text": [text], "target_lang": target });
|
||||||
|
if let Some(source) = Self::source_code(&req.from) {
|
||||||
|
body["source_lang"] = serde_json::json!(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{root}/translate"))
|
||||||
|
.header("Authorization", format!("DeepL-Auth-Key {key}"))
|
||||||
|
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(2_000)))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_reqwest(e, self.cfg.name.as_str()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let raw = resp.text().await.map_err(|e| {
|
||||||
|
TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(classify_http(status.as_u16(), &raw, self.cfg.name.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: DeepLResponse = serde_json::from_str(&raw).map_err(|e| {
|
||||||
|
TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name))
|
||||||
|
.with_detail(&raw)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// DeepL 的翻译结果按 text 数组分段返回;单段请求时取第一段即可
|
||||||
|
let text_out = parsed
|
||||||
|
.translations
|
||||||
|
.first()
|
||||||
|
.and_then(|t| t.text.clone())
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
format!("「{}」未返回译文", self.cfg.name),
|
||||||
|
)
|
||||||
|
.with_detail(raw.trim())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let detected = parsed
|
||||||
|
.translations
|
||||||
|
.first()
|
||||||
|
.and_then(|t| t.detected_source_language.clone())
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
// 上游返回大写(如 "EN"),统一转小写与内部语言码对齐
|
||||||
|
.map(|s| s.to_lowercase());
|
||||||
|
|
||||||
|
Ok(TranslateResult {
|
||||||
|
text: text_out,
|
||||||
|
detected,
|
||||||
|
engine_id: self.cfg.id.clone(),
|
||||||
|
engine_name: self.cfg.name.clone(),
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<String>, TranslateError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test(&self) -> Result<String, TranslateError> {
|
||||||
|
let req = super::EngineRequest {
|
||||||
|
text: PROBE_TEXT.to_string(),
|
||||||
|
from: "en".to_string(),
|
||||||
|
to: "zh-Hans".to_string(),
|
||||||
|
to_label: "简体中文".to_string(),
|
||||||
|
from_label: "英语".to_string(),
|
||||||
|
mode: super::TranslateMode::Translate,
|
||||||
|
image_png: None,
|
||||||
|
via: "preview".to_string(),
|
||||||
|
};
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = self.translate(&req).await?;
|
||||||
|
Ok(format!(
|
||||||
|
"连通正常 · {}ms · 回显「{}」",
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
result.text
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError {
|
||||||
|
if e.is_timeout() {
|
||||||
|
return TranslateError::timeout(format!("请求「{engine}」超时,可调大超时时间"));
|
||||||
|
}
|
||||||
|
if e.is_connect() {
|
||||||
|
return TranslateError::network(format!("无法连接「{engine}」:{e},请检查网络"));
|
||||||
|
}
|
||||||
|
TranslateError::network(format!("请求「{engine}」失败:{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_http(status: u16, body: &str, engine: &str) -> TranslateError {
|
||||||
|
let err = match status {
|
||||||
|
403 => TranslateError::auth(format!(
|
||||||
|
"「{engine}」认证失败(HTTP 403):Key 无效,或免费 Key 用了付费端点(反之亦然)。\
|
||||||
|
免费 Key 应使用 https://api-free.deepl.com/v2"
|
||||||
|
)),
|
||||||
|
456 => TranslateError::rate_limit(format!(
|
||||||
|
"「{engine}」本月翻译额度已用尽(HTTP 456)"
|
||||||
|
)),
|
||||||
|
429 => TranslateError::rate_limit(format!(
|
||||||
|
"「{engine}」请求过于频繁(HTTP 429),请稍后重试"
|
||||||
|
)),
|
||||||
|
400 => TranslateError::config(format!(
|
||||||
|
"「{engine}」拒绝了该请求(HTTP 400):目标语言或文本不合法"
|
||||||
|
)),
|
||||||
|
414 => TranslateError::unsupported(format!(
|
||||||
|
"文本过长,「{engine}」拒绝了该请求(HTTP 414)"
|
||||||
|
)),
|
||||||
|
s if (500..600).contains(&s) => {
|
||||||
|
TranslateError::network(format!("「{engine}」服务端错误(HTTP {status}),可稍后重试"))
|
||||||
|
}
|
||||||
|
s => TranslateError::new(ErrorKind::Unknown, format!("「{engine}」返回 HTTP {s}")),
|
||||||
|
};
|
||||||
|
err.with_detail(body.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DeepLResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
translations: Vec<DeepLTranslation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DeepLTranslation {
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
#[serde(rename = "detected_source_language", default)]
|
||||||
|
detected_source_language: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! LibreTranslate 自托管翻译源。
|
||||||
|
//!
|
||||||
|
//! 定位:用户自部署的开源翻译服务(https://github.com/LibreTranslate/LibreTranslate),
|
||||||
|
//! 数据发往用户自己的服务器,不受境外免费接口的 IP 风控(429)限制。
|
||||||
|
//!
|
||||||
|
//! 接入形态:
|
||||||
|
//! - HTTP:`POST {base}/translate`,body `{"q":..., "source":..., "target":..., "format":"text", "api_key":...}`,
|
||||||
|
//! 返回 `{"translatedText": "...", "detectedLanguage": {"language": ..., "confidence": ...}}`。
|
||||||
|
//! - **语言码不能走 [`super::provider_code`]**:LibreTranslate 用 `zh-Hans` / `zh-Hant` / `en` / `ja`
|
||||||
|
//! 这类代码,与内部语言码一致,原样透传即可;`provider_code` 会把 `zh-Hans` 换成 `zh-CN`,
|
||||||
|
//! 那是 Google / MyMemory 那套写法。
|
||||||
|
//! - 目标语言集取决于部署时的语言包(常见部署仅含 en / ja / zh-Hans 等少数语言);
|
||||||
|
//! 不支持的语言对上游返回 400 + 错误说明,这里把错误说明透传进 detail,而不是猜一个原因。
|
||||||
|
//! - **API Key 可选**:部署启用 `LT_API_KEYS` 后必需;未启用时传不传都行,
|
||||||
|
//! 本实现仅在已配置密钥时才上送 `api_key` 字段。
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{is_auto, ErrorKind, TranslateEngine, TranslateError, TranslateResult};
|
||||||
|
use crate::translate::settings::TranslateEngineConfig;
|
||||||
|
|
||||||
|
const PROBE_TEXT: &str = "Hello, world.";
|
||||||
|
|
||||||
|
pub struct LibreTranslateEngine {
|
||||||
|
cfg: TranslateEngineConfig,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibreTranslateEngine {
|
||||||
|
pub fn new(cfg: TranslateEngineConfig, client: reqwest::Client) -> Self {
|
||||||
|
Self { cfg, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API 根地址(容错:剥掉误粘的 `/translate`,与 DeepL / AI 引擎同一思路)
|
||||||
|
fn api_root(&self) -> Result<String, TranslateError> {
|
||||||
|
let raw = self.cfg.base_url.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"引擎「{}」尚未配置服务地址:请填写自建 LibreTranslate 的地址(如 https://translate.example.com)",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(raw.starts_with("http://") || raw.starts_with("https://")) {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"服务地址需以 http:// 或 https:// 开头,当前为「{raw}」"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut root = raw.trim_end_matches('/').to_string();
|
||||||
|
if let Some(stripped) = root.strip_suffix("/translate") {
|
||||||
|
root = stripped.trim_end_matches('/').to_string();
|
||||||
|
}
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API Key(可选)。部署未启用密钥校验时留空即可。
|
||||||
|
fn api_key(&self) -> Option<String> {
|
||||||
|
let key = crate::translate::engine_api_key(&self.cfg.id);
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(key.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 源语言码:auto 原样上送(LibreTranslate 支持自动检测);显式语言原样透传。
|
||||||
|
fn source_code(&self, from: &str) -> String {
|
||||||
|
if is_auto(from) {
|
||||||
|
"auto".to_string()
|
||||||
|
} else {
|
||||||
|
from.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标语言码:原样透传。不能走 provider_code(见文件头注释)。
|
||||||
|
fn target_code(&self, to: &str) -> Result<String, TranslateError> {
|
||||||
|
let code = to.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err(TranslateError::config("未指定目标语言"));
|
||||||
|
}
|
||||||
|
Ok(code.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TranslateEngine for LibreTranslateEngine {
|
||||||
|
fn config(&self) -> &TranslateEngineConfig {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate(
|
||||||
|
&self,
|
||||||
|
req: &super::EngineRequest,
|
||||||
|
) -> Result<TranslateResult, TranslateError> {
|
||||||
|
let text = req.text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(TranslateError::empty());
|
||||||
|
}
|
||||||
|
let target = self.target_code(&req.to)?;
|
||||||
|
let source = self.source_code(&req.from);
|
||||||
|
let root = self.api_root()?;
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"q": text,
|
||||||
|
"source": source,
|
||||||
|
"target": target,
|
||||||
|
"format": "text",
|
||||||
|
});
|
||||||
|
if let Some(key) = self.api_key() {
|
||||||
|
body["api_key"] = serde_json::json!(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{root}/translate"))
|
||||||
|
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(2_000)))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_reqwest(e, self.cfg.name.as_str()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let raw = resp.text().await.map_err(|e| {
|
||||||
|
TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(classify_http(status.as_u16(), &raw, self.cfg.name.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: LibreTranslateResponse = serde_json::from_str(&raw).map_err(|e| {
|
||||||
|
TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name))
|
||||||
|
.with_detail(&raw)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let text_out = parsed
|
||||||
|
.translated_text
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
format!("「{}」未返回译文(该语言对可能不受支持)", self.cfg.name),
|
||||||
|
)
|
||||||
|
.with_detail(raw.trim())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 仅 source=auto 时上游会回检测结果;显式源语言时该字段缺失
|
||||||
|
let detected = parsed
|
||||||
|
.detected_language
|
||||||
|
.and_then(|d| d.language)
|
||||||
|
.filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
|
Ok(TranslateResult {
|
||||||
|
text: text_out,
|
||||||
|
detected,
|
||||||
|
engine_id: self.cfg.id.clone(),
|
||||||
|
engine_name: self.cfg.name.clone(),
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 免密钥/自托管端点没有「模型」概念
|
||||||
|
async fn list_models(&self) -> Result<Vec<String>, TranslateError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test(&self) -> Result<String, TranslateError> {
|
||||||
|
let req = super::EngineRequest {
|
||||||
|
text: PROBE_TEXT.to_string(),
|
||||||
|
from: "en".to_string(),
|
||||||
|
to: "zh-Hans".to_string(),
|
||||||
|
to_label: "简体中文".to_string(),
|
||||||
|
from_label: "英语".to_string(),
|
||||||
|
mode: super::TranslateMode::Translate,
|
||||||
|
image_png: None,
|
||||||
|
via: "preview".to_string(),
|
||||||
|
};
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = self.translate(&req).await?;
|
||||||
|
Ok(format!(
|
||||||
|
"连通正常 · {}ms · 回显「{}」",
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
result.text
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError {
|
||||||
|
if e.is_timeout() {
|
||||||
|
return TranslateError::timeout(format!("请求「{engine}」超时,可调大超时时间"));
|
||||||
|
}
|
||||||
|
if e.is_connect() {
|
||||||
|
return TranslateError::network(format!("无法连接「{engine}」:{e},请检查服务地址与网络"));
|
||||||
|
}
|
||||||
|
TranslateError::network(format!("请求「{engine}」失败:{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_http(status: u16, body: &str, engine: &str) -> TranslateError {
|
||||||
|
// LibreTranslate 的错误体是 JSON `{"error": "..."}`,取这条说明作 detail 比整段响应可读
|
||||||
|
let detail = serde_json::from_str::<serde_json::Value>(body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||||
|
.unwrap_or_else(|| body.to_string());
|
||||||
|
let err = match status {
|
||||||
|
400 => TranslateError::config(format!(
|
||||||
|
"「{engine}」拒绝了该请求(HTTP 400):目标语言可能不在该部署支持范围内"
|
||||||
|
)),
|
||||||
|
401 => TranslateError::auth(format!(
|
||||||
|
"「{engine}」要求 API Key(HTTP 401):该部署启用了密钥校验,请配置 API Key"
|
||||||
|
)),
|
||||||
|
403 => TranslateError::auth(format!(
|
||||||
|
"「{engine}」API Key 无效(HTTP 403):请检查 Key 与该部署的密钥设置"
|
||||||
|
)),
|
||||||
|
429 => TranslateError::rate_limit(format!(
|
||||||
|
"「{engine}」请求过于频繁(HTTP 429),请稍后重试"
|
||||||
|
)),
|
||||||
|
404 => TranslateError::config(format!(
|
||||||
|
"「{engine}」地址无效(HTTP 404):请确认 Base URL 是 LibreTranslate 服务根地址而非某个页面"
|
||||||
|
)),
|
||||||
|
s if (500..600).contains(&s) => {
|
||||||
|
TranslateError::network(format!("「{engine}」服务端错误(HTTP {status}),可稍后重试"))
|
||||||
|
}
|
||||||
|
s => TranslateError::new(ErrorKind::Unknown, format!("「{engine}」返回 HTTP {s}")),
|
||||||
|
};
|
||||||
|
err.with_detail(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LibreTranslateResponse {
|
||||||
|
#[serde(rename = "translatedText", default)]
|
||||||
|
translated_text: Option<String>,
|
||||||
|
#[serde(rename = "detectedLanguage", default)]
|
||||||
|
detected_language: Option<DetectedLanguage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DetectedLanguage {
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//! 翻译引擎抽象层。
|
||||||
|
//!
|
||||||
|
//! 设计要点:
|
||||||
|
//! - 所有引擎实现同一个 [`TranslateEngine`] trait,命令层只面对 `Box<dyn TranslateEngine>`,
|
||||||
|
//! 因此「多源」与「指定源」都不需要在上层写分支。
|
||||||
|
//! - 错误被细分为 [`ErrorKind`]:网络类失败可以重试到下一个源,认证/额度类失败重试
|
||||||
|
//! 没有意义。前端也据此给出不同提示(「检查 Key」与「检查网络/代理」是两件事)。
|
||||||
|
//! - 目标语言一律传**自然语言全称**给模型(`to_label`),语言码只用于记录与查询——
|
||||||
|
//! 模型对「繁体中文」的遵循度明显高于 `zh-Hant`。
|
||||||
|
|
||||||
|
pub mod ai;
|
||||||
|
pub mod deepl;
|
||||||
|
pub mod libretranslate;
|
||||||
|
pub mod mymemory;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use specta::Type;
|
||||||
|
|
||||||
|
use super::settings::{PromptTemplates, TranslateEngineConfig};
|
||||||
|
|
||||||
|
/// 翻译模式(P0 只用 Translate;其余三档为 P3 的同入口扩展预留)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TranslateMode {
|
||||||
|
Translate,
|
||||||
|
Polish,
|
||||||
|
Explain,
|
||||||
|
Summarize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranslateMode {
|
||||||
|
/// 宽松解析:未知值一律按「翻译」处理,不因前端多传一个枚举值而报错。
|
||||||
|
pub fn parse(raw: Option<&str>) -> Self {
|
||||||
|
match raw.unwrap_or("").trim() {
|
||||||
|
"polish" => Self::Polish,
|
||||||
|
"explain" => Self::Explain,
|
||||||
|
"summarize" => Self::Summarize,
|
||||||
|
_ => Self::Translate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 引擎请求(内部结构,不参与类型绑定导出)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EngineRequest {
|
||||||
|
/// 待处理文本
|
||||||
|
pub text: String,
|
||||||
|
/// 源语言代码,"auto" 表示自动检测
|
||||||
|
pub from: String,
|
||||||
|
/// 目标语言代码(如 zh-Hans)
|
||||||
|
pub to: String,
|
||||||
|
/// 目标语言自然语言全称(如 简体中文),提示词用
|
||||||
|
pub to_label: String,
|
||||||
|
/// 源语言自然语言全称,auto 时为空
|
||||||
|
pub from_label: String,
|
||||||
|
pub mode: TranslateMode,
|
||||||
|
/// 图像输入(base64 PNG,不含 data: 前缀)。仅截图翻译的「视觉直译」模式使用。
|
||||||
|
/// 免密钥源与未声明 supports_vision 的 AI 引擎会拒绝该请求。
|
||||||
|
pub image_png: Option<String>,
|
||||||
|
/// 记入历史时的来源标签:"manual" | "selection" | "clipboard" | "screenshot" | "preview"
|
||||||
|
pub via: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token 用量(AI 引擎返回;其余引擎为 None)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TokenUsage {
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 翻译结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TranslateResult {
|
||||||
|
/// 译文
|
||||||
|
pub text: String,
|
||||||
|
/// 检测到的源语言(免费源会上报;AI 引擎为显式指定值或 None)
|
||||||
|
pub detected: Option<String>,
|
||||||
|
/// 实际使用的引擎实例 id
|
||||||
|
pub engine_id: String,
|
||||||
|
/// 实际使用的引擎展示名(自动降级时前端要能看出「是谁答的」)
|
||||||
|
pub engine_name: String,
|
||||||
|
/// 耗时(毫秒)
|
||||||
|
pub latency_ms: u64,
|
||||||
|
pub usage: Option<TokenUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误分类。区分它们的意义在于**前端能给出可操作的提示**:
|
||||||
|
/// `Auth` 要用户去改 Key,`Network` 要用户查网络/代理,`RateLimit` 只需等待。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ErrorKind {
|
||||||
|
/// 配置缺失或不合法(未填 Base URL、模型名无效等)
|
||||||
|
Config,
|
||||||
|
/// 认证失败 / 无权限 / 额度耗尽
|
||||||
|
Auth,
|
||||||
|
/// 被限流
|
||||||
|
RateLimit,
|
||||||
|
/// 网络不可达(含代理问题)
|
||||||
|
Network,
|
||||||
|
/// 超时
|
||||||
|
Timeout,
|
||||||
|
/// 响应无法解析(上游改了格式)
|
||||||
|
Parse,
|
||||||
|
/// 不支持的引擎类型或语言对
|
||||||
|
Unsupported,
|
||||||
|
/// 输入为空(取词失败或用户未输入)
|
||||||
|
Empty,
|
||||||
|
/// 被风控/验证码拦截(免费源常见)
|
||||||
|
Captcha,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结构化错误:作为 Tauri 命令的 error 类型返回,前端按 `kind` 分支处理。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TranslateError {
|
||||||
|
pub kind: ErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
/// 上游原始响应片段(已截断),用于排查;不含密钥
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上游响应片段入库前的截断长度(避免把整页 HTML 塞进错误对象)
|
||||||
|
const DETAIL_LIMIT: usize = 400;
|
||||||
|
|
||||||
|
impl TranslateError {
|
||||||
|
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
detail: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
|
||||||
|
let raw = detail.into();
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let clipped: String = trimmed.chars().take(DETAIL_LIMIT).collect();
|
||||||
|
self.detail = Some(clipped);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Config, msg)
|
||||||
|
}
|
||||||
|
pub fn auth(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Auth, msg)
|
||||||
|
}
|
||||||
|
pub fn rate_limit(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::RateLimit, msg)
|
||||||
|
}
|
||||||
|
pub fn network(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Network, msg)
|
||||||
|
}
|
||||||
|
pub fn timeout(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Timeout, msg)
|
||||||
|
}
|
||||||
|
pub fn parse(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Parse, msg)
|
||||||
|
}
|
||||||
|
pub fn unsupported(msg: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorKind::Unsupported, msg)
|
||||||
|
}
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::new(ErrorKind::Empty, "没有可翻译的内容")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该错误是否值得「换一个源再试」。
|
||||||
|
///
|
||||||
|
/// 只有 `Empty` 不可重试:输入本身为空,换任何源结果都一样。
|
||||||
|
/// `Unsupported`(本源超长上限 / 不支持该语言对 / 类型未接入)**必须可降级**——
|
||||||
|
/// 「这个源处理不了这个请求」的含义就是「换下一个」,否则配了
|
||||||
|
/// MyMemory + DeepSeek 的用户翻译一篇超长文本会整体失败(MyMemory 优先时)。
|
||||||
|
pub fn retryable(&self) -> bool {
|
||||||
|
self.kind != ErrorKind::Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TranslateError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TranslateError {}
|
||||||
|
|
||||||
|
/// 把模板中的占位符替换为实际语言名。
|
||||||
|
pub fn render_template(template: &str, req: &EngineRequest) -> String {
|
||||||
|
let target = if req.to_label.trim().is_empty() {
|
||||||
|
req.to.as_str()
|
||||||
|
} else {
|
||||||
|
req.to_label.as_str()
|
||||||
|
};
|
||||||
|
let source = if req.from_label.trim().is_empty() {
|
||||||
|
"原文的语言(自动判断)"
|
||||||
|
} else {
|
||||||
|
req.from_label.as_str()
|
||||||
|
};
|
||||||
|
template
|
||||||
|
.replace("{target}", target)
|
||||||
|
.replace("{source}", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 翻译模式下统一附加的「混排」指令。
|
||||||
|
///
|
||||||
|
/// 截图 OCR 经常抽出中英日混排的文本;显式源语言提示(如「源语言:日语」)反而
|
||||||
|
/// 会诱导模型把与目标语言字形相同的部分(日文汉字)当作「已经是译文」原样保留
|
||||||
|
/// ——这正是「日转中却总有一些日语没被翻译」的主因。
|
||||||
|
///
|
||||||
|
/// 固定追加在 user 内容末尾而不是改进提示词模板:模板是用户已保存的存量设置,
|
||||||
|
/// 改默认值对老用户不生效;这条属于引擎层的硬约束,不该被模板覆盖。
|
||||||
|
const MIXED_LANG_NOTE: &str = "注意:原文可能是多语言混排(如中英日混排)。\
|
||||||
|
请把全部内容统一译为目标语言——包括与目标语言字形相同的文字(如日文汉字)也要译,\
|
||||||
|
不要原样保留;仅专有名词可保留原文。";
|
||||||
|
|
||||||
|
/// 构造送给模型的用户内容。
|
||||||
|
///
|
||||||
|
/// 纯文本时:仅在**显式指定了源语言**时补一行提示——模型对「源语言是日语」的显式声明
|
||||||
|
/// 比让它自行判断更稳;不指定时什么都不加,避免制造无谓的 token 与
|
||||||
|
/// 「别输出提示」的博弈。
|
||||||
|
///
|
||||||
|
/// 带图像时:改为 OpenAI 视觉消息的 content-parts 结构(文本段 + image_url 段),
|
||||||
|
/// 与 `https://platform.openai.com/docs/guides/vision` 的请求体一致;
|
||||||
|
/// 兼容该格式的服务(DeepSeek 的实验视觉模型、通义、GLM-4V 等)可直接受理。
|
||||||
|
pub fn build_user_content(req: &EngineRequest, supports_vision: bool) -> serde_json::Value {
|
||||||
|
let source_hint = if req.mode == TranslateMode::Translate
|
||||||
|
&& !req.from.trim().is_empty()
|
||||||
|
&& req.from != "auto"
|
||||||
|
&& !req.from_label.trim().is_empty()
|
||||||
|
{
|
||||||
|
Some(format!("源语言:{}", req.from_label))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
match &req.image_png {
|
||||||
|
Some(data_url) if supports_vision => {
|
||||||
|
let mut parts = vec![];
|
||||||
|
if let Some(hint) = source_hint {
|
||||||
|
parts.push(json!({ "type": "text", "text": hint }));
|
||||||
|
}
|
||||||
|
parts.push(json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": "识别图中的文字并按系统提示翻译。保持原有排版与分段。"
|
||||||
|
}));
|
||||||
|
if req.mode == TranslateMode::Translate {
|
||||||
|
parts.push(json!({ "type": "text", "text": MIXED_LANG_NOTE }));
|
||||||
|
}
|
||||||
|
parts.push(json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": { "url": format!("data:image/png;base64,{data_url}") }
|
||||||
|
}));
|
||||||
|
json!(parts)
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
// 带图但引擎不支持:这不该发生(命令层已拦截),兜底只发文本说明,
|
||||||
|
// 而不是让请求带着一个模型无法理解的字段出去
|
||||||
|
let note = "(请求包含图像,但当前引擎不支持图像输入,仅能处理文本。)";
|
||||||
|
json!(format!("{}{}", note, req.text))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let text = req.text.clone();
|
||||||
|
let base = match source_hint {
|
||||||
|
Some(hint) => format!("{hint}\n\n{text}"),
|
||||||
|
None => text,
|
||||||
|
};
|
||||||
|
// 翻译模式统一追加混排指令;其他模式(润色/解释/总结)语义不同,不追加
|
||||||
|
if req.mode == TranslateMode::Translate {
|
||||||
|
json!(format!("{base}\n\n{MIXED_LANG_NOTE}"))
|
||||||
|
} else {
|
||||||
|
json!(base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 参数对齐后的公共请求体骨架(`model` / `messages` / `stream` 由各引擎补充)。
|
||||||
|
pub fn apply_common_params(
|
||||||
|
body: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
cfg: &TranslateEngineConfig,
|
||||||
|
) {
|
||||||
|
body.insert("temperature".to_string(), serde_json::json!(cfg.temperature));
|
||||||
|
body.insert("max_tokens".to_string(), serde_json::json!(cfg.max_tokens));
|
||||||
|
if let Some(extra) = cfg.extra_body.as_deref().map(str::trim) {
|
||||||
|
if !extra.is_empty() {
|
||||||
|
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(extra) {
|
||||||
|
for (k, v) in map {
|
||||||
|
body.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解析失败按「无额外参数」处理:不能让一次笔误导致整个翻译不可用,
|
||||||
|
// 具体错误由命令层的 translate_engine_test 反馈给用户。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式翻译的单条事件。
|
||||||
|
///
|
||||||
|
/// 引擎 → 命令层用 `mpsc` 通道传递(引擎不持有 AppHandle,保持传输层与 UI 层分离),
|
||||||
|
/// 命令层再转发为 Tauri 事件给前端。
|
||||||
|
///
|
||||||
|
/// **序列化形态是前端契约的一部分**:`tag = "type"` 让事件扁平化为
|
||||||
|
/// `{"type":"chunk","requestId":"...","delta":"..."}`,而不是 serde 默认的外层标签
|
||||||
|
/// `{"Chunk":{...}}`。前端按 `requestId` 过滤事件,多一层嵌套会让 `requestId` 取到
|
||||||
|
/// `undefined`、所有事件被丢弃——表现为「历史里有结果,界面上一片空白」。
|
||||||
|
///
|
||||||
|
/// **注意 serde 的一个坑**:`rename_all` 用在枚举上只重命名**变体名**(Chunk → chunk),
|
||||||
|
/// 不作用于变体内的字段——`request_id` 会原样序列化成 `request_id`,前端拿
|
||||||
|
/// `payload.requestId` 永远是 undefined。因此每个变体的 `request_id` 字段都显式
|
||||||
|
/// `#[serde(rename = "requestId")]`,别合并成 `rename_all_fields`(依赖 serde 版本)。
|
||||||
|
/// 改动这里必须同步改 `src/lib/translate/api.ts` 的 `StreamEventPayload`。
|
||||||
|
///
|
||||||
|
/// `Error` 变体是**兜底通道**:主通道仍是命令的 `Err`(Promise reject)。保留它
|
||||||
|
/// 是为了覆盖「命令已返回 requestId、之后才失败」的情形,否则前端会永远停在
|
||||||
|
/// 「翻译中」而没有出口。
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum StreamEvent {
|
||||||
|
/// 增量片段
|
||||||
|
Chunk {
|
||||||
|
#[serde(rename = "requestId")]
|
||||||
|
request_id: String,
|
||||||
|
delta: String,
|
||||||
|
},
|
||||||
|
/// 完成(携带完整结果,含实际引擎名)
|
||||||
|
Done {
|
||||||
|
#[serde(rename = "requestId")]
|
||||||
|
request_id: String,
|
||||||
|
result: TranslateResult,
|
||||||
|
},
|
||||||
|
/// 失败(兜底通道,见类型注释)
|
||||||
|
Error {
|
||||||
|
#[serde(rename = "requestId")]
|
||||||
|
request_id: String,
|
||||||
|
error: TranslateError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 引擎统一接口。
|
||||||
|
///
|
||||||
|
/// 用 `async_trait` 而非原生 `async fn in trait`:本 trait 需要 `Box<dyn>` 动态分派
|
||||||
|
/// (多源切换是运行期决定的),原生 async fn 在 dyn 场景下不可用。
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait TranslateEngine: Send + Sync {
|
||||||
|
/// 引擎配置。id / name 等一律由此读取,避免在每个实现里重复存字段。
|
||||||
|
fn config(&self) -> &TranslateEngineConfig;
|
||||||
|
|
||||||
|
/// 展示名(自动降级时用于记录「是谁失败了」)
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.config().name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行一次翻译
|
||||||
|
async fn translate(&self, req: &EngineRequest) -> Result<TranslateResult, TranslateError>;
|
||||||
|
|
||||||
|
/// 流式翻译:增量经 `tx` 下发,结束时发一条 [`StreamEvent::Done`]。
|
||||||
|
///
|
||||||
|
/// 默认实现是「同步翻译后整体下发」——**不支持流式的引擎不需要实现它**,
|
||||||
|
/// 前端因此可以统一走流式入口,无需自己判断引擎能力。
|
||||||
|
/// 通道关闭(`send` 失败)意味着调用方已放弃本次请求,实现方应尽快返回而不是报错。
|
||||||
|
async fn translate_stream(
|
||||||
|
&self,
|
||||||
|
req: &EngineRequest,
|
||||||
|
request_id: String,
|
||||||
|
tx: tokio::sync::mpsc::Sender<StreamEvent>,
|
||||||
|
) -> Result<(), TranslateError> {
|
||||||
|
let result = self.translate(req).await?;
|
||||||
|
let _ = tx.send(StreamEvent::Done { request_id, result }).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取可用模型列表(用于「上游改名」这类失效场景的自救入口)
|
||||||
|
async fn list_models(&self) -> Result<Vec<String>, TranslateError>;
|
||||||
|
|
||||||
|
/// 连通性自检:返回一句人类可读的成功描述(含实际模型回显)
|
||||||
|
async fn test(&self) -> Result<String, TranslateError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内部语言码 → 第三方源使用的语言码。
|
||||||
|
///
|
||||||
|
/// 只有中文需要换算:内部统一用 BCP-47 的 `zh-Hans` / `zh-Hant`,而 MyMemory
|
||||||
|
/// 沿用 `zh-CN` / `zh-TW` 这一代写法。其余语言码两边一致,原样透传——
|
||||||
|
/// 与其维护一张可能过期的全量映射表,不如只处理确有差异的项。
|
||||||
|
/// 注意 LibreTranslate 用的是 `zh-Hans` / `zh-Hant`,与内部一致,**不走本函数**。
|
||||||
|
pub fn provider_code(code: &str) -> String {
|
||||||
|
match code {
|
||||||
|
"zh-Hans" => "zh-CN".to_string(),
|
||||||
|
"zh-Hant" => "zh-TW".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 免费源的语言支持是「尽力而为」的:第三方接口对不支持的语言对通常返回
|
||||||
|
/// 空译文而不是明确报错,因此统一在这里识别,给出可操作的提示。
|
||||||
|
pub fn is_auto(code: &str) -> bool {
|
||||||
|
let c = code.trim();
|
||||||
|
c.is_empty() || c == "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::StreamEvent;
|
||||||
|
|
||||||
|
/// 前端契约测试:事件必须扁平化为 `{"type":"chunk","requestId":...}`。
|
||||||
|
/// 守护枚举字段命名的 serde 坑(rename_all 在枚举上不改字段名),
|
||||||
|
/// 一旦回退,前端所有流式事件都会因 requestId 取到 undefined 被丢弃。
|
||||||
|
#[test]
|
||||||
|
fn stream_event_serializes_flat_with_camel_case_request_id() {
|
||||||
|
let json = serde_json::to_value(StreamEvent::Chunk {
|
||||||
|
request_id: "r1".into(),
|
||||||
|
delta: "x".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(json["type"], "chunk", "实际形态: {json}");
|
||||||
|
assert_eq!(json["requestId"], "r1", "实际形态: {json}");
|
||||||
|
assert!(json.get("request_id").is_none(), "实际形态: {json}");
|
||||||
|
|
||||||
|
let json = serde_json::to_value(StreamEvent::Done {
|
||||||
|
request_id: "r1".into(),
|
||||||
|
result: super::TranslateResult {
|
||||||
|
text: "t".into(),
|
||||||
|
detected: None,
|
||||||
|
engine_id: "e".into(),
|
||||||
|
engine_name: "n".into(),
|
||||||
|
latency_ms: 1,
|
||||||
|
usage: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(json["type"], "done", "实际形态: {json}");
|
||||||
|
assert_eq!(json["requestId"], "r1", "实际形态: {json}");
|
||||||
|
assert_eq!(json["result"]["engineName"], "n", "实际形态: {json}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依据配置构造引擎实例。
|
||||||
|
///
|
||||||
|
/// P0/P1 已实现:`ai`(OpenAI 兼容家族)与 `free` 下的 libretranslate / mymemory;
|
||||||
|
/// `cloud`(需签名的云厂商)在 P3。未实现的类型显式返回 `Unsupported` 而不是静默忽略,
|
||||||
|
/// 避免用户配了却「以为生效」。
|
||||||
|
pub fn build_engine(
|
||||||
|
cfg: &TranslateEngineConfig,
|
||||||
|
templates: &PromptTemplates,
|
||||||
|
client: reqwest::Client,
|
||||||
|
) -> Result<Box<dyn TranslateEngine>, TranslateError> {
|
||||||
|
match cfg.kind.as_str() {
|
||||||
|
"ai" => Ok(Box::new(ai::AiEngine::new(
|
||||||
|
cfg.clone(),
|
||||||
|
templates.clone(),
|
||||||
|
client,
|
||||||
|
))),
|
||||||
|
"free" => match cfg.preset.as_str() {
|
||||||
|
"libretranslate" => Ok(Box::new(libretranslate::LibreTranslateEngine::new(
|
||||||
|
cfg.clone(),
|
||||||
|
client,
|
||||||
|
))),
|
||||||
|
"mymemory" => Ok(Box::new(mymemory::MyMemoryEngine::new(cfg.clone(), client))),
|
||||||
|
other => Err(TranslateError::unsupported(format!(
|
||||||
|
"免密钥源「{other}」尚未接入(可选:libretranslate / mymemory)"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
"cloud" => match cfg.preset.as_str() {
|
||||||
|
"deepl" => Ok(Box::new(deepl::DeepLEngine::new(cfg.clone(), client))),
|
||||||
|
other => Err(TranslateError::unsupported(format!(
|
||||||
|
"云厂商源「{other}」尚未接入(可选:deepl)"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
other => Err(TranslateError::config(format!(
|
||||||
|
"未知的引擎类型「{other}」(仅支持 ai / free / cloud)"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! MyMemory 免密钥翻译源。
|
||||||
|
//!
|
||||||
|
//! 定位:国内可直接访问的免密兜底。质量不如大模型,但胜在零配置、无代理依赖。
|
||||||
|
//!
|
||||||
|
//! 两个必须知道的限制:
|
||||||
|
//! - **不支持自动检测源语言**。`langpair` 要求显式源语言,因此源语言为 `auto` 时
|
||||||
|
//! 直接返回可操作的配置错误(而不是发一个注定失败的请求),让「自动」模式降级到下一个源。
|
||||||
|
//! - 免费额度按**字节**计(约 500 字节/次,匿名另有每日上限)。超限时上游返回
|
||||||
|
//! `responseStatus: 403` 并在 `responseDetails` 里说明,这里原样透传给用户看。
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
is_auto, provider_code, ErrorKind, TranslateEngine, TranslateError, TranslateResult,
|
||||||
|
};
|
||||||
|
use crate::translate::settings::TranslateEngineConfig;
|
||||||
|
|
||||||
|
const ENDPOINT: &str = "https://api.mymemory.translated.net/get";
|
||||||
|
|
||||||
|
/// 单次查询的字节上限(上游按字节限制,且 langpair 也占额度的一部分)
|
||||||
|
const MAX_QUERY_BYTES: usize = 500;
|
||||||
|
|
||||||
|
const PROBE_TEXT: &str = "Hello, world.";
|
||||||
|
|
||||||
|
pub struct MyMemoryEngine {
|
||||||
|
cfg: TranslateEngineConfig,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyMemoryEngine {
|
||||||
|
pub fn new(cfg: TranslateEngineConfig, client: reqwest::Client) -> Self {
|
||||||
|
Self { cfg, client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TranslateEngine for MyMemoryEngine {
|
||||||
|
fn config(&self) -> &TranslateEngineConfig {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate(
|
||||||
|
&self,
|
||||||
|
req: &super::EngineRequest,
|
||||||
|
) -> Result<TranslateResult, TranslateError> {
|
||||||
|
let text = req.text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(TranslateError::empty());
|
||||||
|
}
|
||||||
|
if is_auto(&req.from) {
|
||||||
|
// 不猜:源语言未知时上游无法工作,明确返回配置类错误(可降级)。
|
||||||
|
// 文案要同时覆盖两个入口:主面板的源语言下拉、划词弹窗顶部的「自」循环按钮
|
||||||
|
// ——弹窗的源语言是独立记忆项,与主面板的选择无关,不点明用户会以为
|
||||||
|
// 「主界面选过了为什么还报错」。
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"「{}」需要显式指定源语言:主面板请在源语言下拉中选具体语言;\
|
||||||
|
划词弹窗请点击顶部的「自」按钮切换源语言(选择会被记住)",
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if text.len() > MAX_QUERY_BYTES {
|
||||||
|
return Err(TranslateError::unsupported(format!(
|
||||||
|
"文本 {} 字节,超过「{}」的单次上限({MAX_QUERY_BYTES} 字节)",
|
||||||
|
text.len(),
|
||||||
|
self.cfg.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let target = provider_code(&req.to);
|
||||||
|
if target.trim().is_empty() {
|
||||||
|
return Err(TranslateError::config("未指定目标语言"));
|
||||||
|
}
|
||||||
|
let pair = format!("{}|{}", provider_code(&req.from), target);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(ENDPOINT)
|
||||||
|
.query(&[("q", text), ("langpair", pair.as_str())])
|
||||||
|
.timeout(Duration::from_millis(self.cfg.timeout_ms.max(2_000)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_reqwest(e, self.cfg.name.as_str()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let raw = resp.text().await.map_err(|e| {
|
||||||
|
TranslateError::network(format!("读取「{}」响应失败: {e}", self.cfg.name))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(TranslateError::network(format!(
|
||||||
|
"「{}」返回 HTTP {status}",
|
||||||
|
self.cfg.name
|
||||||
|
))
|
||||||
|
.with_detail(raw.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: MyMemoryResponse = serde_json::from_str(&raw).map_err(|e| {
|
||||||
|
TranslateError::parse(format!("「{}」响应不是预期的 JSON: {e}", self.cfg.name))
|
||||||
|
.with_detail(&raw)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// responseStatus 可能是数字 200 也可能是字符串 "403",统一取整再判断
|
||||||
|
let code = parsed
|
||||||
|
.response_status
|
||||||
|
.as_ref()
|
||||||
|
.and_then(status_as_u64);
|
||||||
|
if let Some(c) = code {
|
||||||
|
if c != 200 {
|
||||||
|
let detail = parsed
|
||||||
|
.response_details
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "上游未提供说明".to_string());
|
||||||
|
let kind = if c == 403 { ErrorKind::RateLimit } else { ErrorKind::Unknown };
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
kind,
|
||||||
|
format!("「{}」拒绝了请求(status={c})", self.cfg.name),
|
||||||
|
)
|
||||||
|
.with_detail(detail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_out = parsed
|
||||||
|
.response_data
|
||||||
|
.and_then(|d| d.translated_text)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let out = match text_out {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
// 免费额度用尽时也会走到这里,把上游说明带上,用户才知所以然
|
||||||
|
let detail = parsed
|
||||||
|
.response_details
|
||||||
|
.filter(|d| !d.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| raw.clone());
|
||||||
|
return Err(TranslateError::new(
|
||||||
|
ErrorKind::Empty,
|
||||||
|
format!("「{}」未返回译文(额度用尽或语言对不支持)", self.cfg.name),
|
||||||
|
)
|
||||||
|
.with_detail(detail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(TranslateResult {
|
||||||
|
text: out,
|
||||||
|
// 源语言是用户显式指定的,没有可上报的检测结果
|
||||||
|
detected: None,
|
||||||
|
engine_id: self.cfg.id.clone(),
|
||||||
|
engine_name: self.cfg.name.clone(),
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<String>, TranslateError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test(&self) -> Result<String, TranslateError> {
|
||||||
|
let req = super::EngineRequest {
|
||||||
|
text: PROBE_TEXT.to_string(),
|
||||||
|
from: "en".to_string(),
|
||||||
|
to: "zh-Hans".to_string(),
|
||||||
|
to_label: "简体中文".to_string(),
|
||||||
|
from_label: "英语".to_string(),
|
||||||
|
mode: super::TranslateMode::Translate,
|
||||||
|
image_png: None,
|
||||||
|
via: "preview".to_string(),
|
||||||
|
};
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = self.translate(&req).await?;
|
||||||
|
Ok(format!(
|
||||||
|
"连通正常 · {}ms · 回显「{}」",
|
||||||
|
started.elapsed().as_millis(),
|
||||||
|
result.text
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从数字或字符串形式的 status 字段取整
|
||||||
|
fn status_as_u64(v: &serde_json::Value) -> Option<u64> {
|
||||||
|
match v {
|
||||||
|
serde_json::Value::Number(n) => n.as_u64(),
|
||||||
|
serde_json::Value::String(s) => s.trim().parse::<u64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_reqwest(e: reqwest::Error, engine: &str) -> TranslateError {
|
||||||
|
if e.is_timeout() {
|
||||||
|
return TranslateError::timeout(format!(
|
||||||
|
"请求「{engine}」超时,可调大超时时间或检查网络"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if e.is_connect() {
|
||||||
|
return TranslateError::network(format!("无法连接「{engine}」:{e},请检查网络"));
|
||||||
|
}
|
||||||
|
TranslateError::network(format!("请求「{engine}」失败:{e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct MyMemoryResponse {
|
||||||
|
#[serde(rename = "responseData", default)]
|
||||||
|
response_data: Option<ResponseData>,
|
||||||
|
#[serde(rename = "responseStatus", default)]
|
||||||
|
response_status: Option<serde_json::Value>,
|
||||||
|
#[serde(rename = "responseDetails", default)]
|
||||||
|
response_details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResponseData {
|
||||||
|
#[serde(rename = "translatedText", default)]
|
||||||
|
translated_text: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
//! 翻译历史(SQLite)。
|
||||||
|
//!
|
||||||
|
//! 存储约定:`{app_data_dir}/translate/history.db`。去重键是
|
||||||
|
//! `(source_text, to_lang, engine_id)` —— 同一段文字、同一个目标语言、同一个引擎
|
||||||
|
//! 只保留一条,重复翻译只更新时间与译文。**不含 `via`**:划词翻过的句子再用主面板翻,
|
||||||
|
//! 是同一件事,拆成两条只会让历史变得难搜。
|
||||||
|
//!
|
||||||
|
//! 收藏条目不参与容量淘汰(`prune_to_max`)——用户明确说「留着」的东西,
|
||||||
|
//! 不该因为新记录挤进来而消失。
|
||||||
|
//!
|
||||||
|
//! 搜索走 FTS5 三元组索引(`history_fts`),详见 `FTS_MIN_CHARS` 与 `fts_phrase` 的说明。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::Serialize;
|
||||||
|
use specta::Type;
|
||||||
|
|
||||||
|
/// 库结构版本。
|
||||||
|
///
|
||||||
|
/// 与本值不等的库在打开时**整库重建**(见 `History::new`)。现阶段模块仍在开发、
|
||||||
|
/// 未实装,历史属于可丢弃数据,因此不做增量迁移——维护一堆迁移分支、还要处理
|
||||||
|
/// 「迁移到一半失败」留下的半新半旧库,成本远高于丢掉几条测试记录。
|
||||||
|
/// **实装之后再改结构就必须换成真正的迁移。**
|
||||||
|
const SCHEMA_VERSION: i64 = 2;
|
||||||
|
|
||||||
|
/// 走 FTS 索引所需的最小字符数。
|
||||||
|
///
|
||||||
|
/// trigram 分词器把文本切成连续 3 字符的 n-gram,索引里不存在长度小于 3 的片段,
|
||||||
|
/// 因此 1~2 个字符的 MATCH **不会报错,只会静默返回空结果**。「条件明明对却搜不到」
|
||||||
|
/// 比「慢一点」糟糕得多(中文里两字词又恰恰最常见),所以短词回退到 LIKE 全表扫描:
|
||||||
|
/// 此时无论走哪条路都谈不上选择性,扫描是可接受的代价。
|
||||||
|
const FTS_MIN_CHARS: usize = 3;
|
||||||
|
|
||||||
|
/// 一条历史记录
|
||||||
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HistoryItem {
|
||||||
|
pub id: i64,
|
||||||
|
/// 毫秒时间戳
|
||||||
|
pub ts: i64,
|
||||||
|
pub from_lang: String,
|
||||||
|
pub to_lang: String,
|
||||||
|
pub engine_id: String,
|
||||||
|
pub engine_name: String,
|
||||||
|
pub source_text: String,
|
||||||
|
pub result_text: String,
|
||||||
|
/// 来源:"manual" | "selection" | "clipboard" | "screenshot" | "preview"
|
||||||
|
pub via: String,
|
||||||
|
pub favorited: bool,
|
||||||
|
pub latency_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct History {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全部建表语句。结构版本变化时整库重建,所以这里不需要考虑兼容旧结构。
|
||||||
|
///
|
||||||
|
/// `history_fts` 是**外部内容表**(`content='history'`):索引只存倒排表,
|
||||||
|
/// 正文仍只留在 `history` 里一份,不做双份存储。代价是它不会自己感知主表变化,
|
||||||
|
/// 必须靠下面三个触发器手动同步——漏了任何一个,索引就会和主表静默错位。
|
||||||
|
const SCHEMA_SQL: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
from_lang TEXT NOT NULL,
|
||||||
|
to_lang TEXT NOT NULL,
|
||||||
|
engine_id TEXT NOT NULL,
|
||||||
|
engine_name TEXT NOT NULL DEFAULT '',
|
||||||
|
source_text TEXT NOT NULL,
|
||||||
|
result_text TEXT NOT NULL,
|
||||||
|
via TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(source_text, to_lang, engine_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_fav ON history(favorited, ts DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
|
||||||
|
source_text, result_text,
|
||||||
|
content='history', content_rowid='id',
|
||||||
|
tokenize='trigram'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS history_fts_ai AFTER INSERT ON history BEGIN
|
||||||
|
INSERT INTO history_fts(rowid, source_text, result_text)
|
||||||
|
VALUES (new.id, new.source_text, new.result_text);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS history_fts_ad AFTER DELETE ON history BEGIN
|
||||||
|
INSERT INTO history_fts(history_fts, rowid, source_text, result_text)
|
||||||
|
VALUES ('delete', old.id, old.source_text, old.result_text);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS history_fts_au AFTER UPDATE ON history BEGIN
|
||||||
|
INSERT INTO history_fts(history_fts, rowid, source_text, result_text)
|
||||||
|
VALUES ('delete', old.id, old.source_text, old.result_text);
|
||||||
|
INSERT INTO history_fts(rowid, source_text, result_text)
|
||||||
|
VALUES (new.id, new.source_text, new.result_text);
|
||||||
|
END;
|
||||||
|
";
|
||||||
|
|
||||||
|
impl History {
|
||||||
|
pub fn new(dir: &Path) -> Result<Self, String> {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| format!("创建翻译目录失败: {e}"))?;
|
||||||
|
let conn =
|
||||||
|
Connection::open(dir.join("history.db")).map_err(|e| format!("打开历史库失败: {e}"))?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode = WAL;")
|
||||||
|
.map_err(|e| format!("初始化历史库失败: {e}"))?;
|
||||||
|
|
||||||
|
let version: i64 = conn
|
||||||
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||||
|
.map_err(|e| format!("读取历史库版本失败: {e}"))?;
|
||||||
|
if version != SCHEMA_VERSION {
|
||||||
|
// 先删 FTS 表再删主表:触发器挂在主表上,会跟着一起消失
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP TABLE IF EXISTS history_fts;
|
||||||
|
DROP TABLE IF EXISTS history;",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("重建历史库失败: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch(SCHEMA_SQL)
|
||||||
|
.map_err(|e| format!("初始化历史表失败: {e}"))?;
|
||||||
|
conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))
|
||||||
|
.map_err(|e| format!("写入历史库版本失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||||
|
self.conn.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录一次翻译(去重:命中则更新时间与译文)。
|
||||||
|
pub fn record(
|
||||||
|
&self,
|
||||||
|
from_lang: &str,
|
||||||
|
to_lang: &str,
|
||||||
|
engine_id: &str,
|
||||||
|
engine_name: &str,
|
||||||
|
source_text: &str,
|
||||||
|
result_text: &str,
|
||||||
|
via: &str,
|
||||||
|
latency_ms: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if source_text.trim().is_empty() || result_text.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
self.conn()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO history (ts, from_lang, to_lang, engine_id, engine_name,
|
||||||
|
source_text, result_text, via, favorited, latency_ms)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9)
|
||||||
|
ON CONFLICT(source_text, to_lang, engine_id)
|
||||||
|
DO UPDATE SET ts = excluded.ts, result_text = excluded.result_text,
|
||||||
|
via = excluded.via, latency_ms = excluded.latency_ms",
|
||||||
|
params![
|
||||||
|
now,
|
||||||
|
from_lang,
|
||||||
|
to_lang,
|
||||||
|
engine_id,
|
||||||
|
engine_name,
|
||||||
|
source_text,
|
||||||
|
result_text,
|
||||||
|
via,
|
||||||
|
latency_ms
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("写入历史失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表。`query` 非空时按原文/译文模糊匹配。
|
||||||
|
///
|
||||||
|
/// 两种 SQL 形态互斥,但**参数个数与顺序固定为 (limit, offset, ?3)**,
|
||||||
|
/// 这样绑定点不需要跟着分支走:
|
||||||
|
/// - 关键词 ≥ `FTS_MIN_CHARS` 走 FTS5 索引(`history_fts MATCH`);
|
||||||
|
/// - 更短(或没有关键词)走 LIKE。
|
||||||
|
///
|
||||||
|
/// 两条路径都是子串语义、都忽略 ASCII 大小写,行为一致。
|
||||||
|
pub fn list(
|
||||||
|
&self,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
query: Option<&str>,
|
||||||
|
favorited_only: bool,
|
||||||
|
) -> Result<Vec<HistoryItem>, String> {
|
||||||
|
let term = query.map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
let use_fts = term.is_some_and(|t| t.chars().count() >= FTS_MIN_CHARS);
|
||||||
|
|
||||||
|
let mut sql = String::new();
|
||||||
|
if use_fts {
|
||||||
|
// 列必须带 h. 前缀:source_text / result_text 在两张表里同名,不加限定会歧义。
|
||||||
|
sql.push_str(
|
||||||
|
"SELECT h.id, h.ts, h.from_lang, h.to_lang, h.engine_id, h.engine_name,
|
||||||
|
h.source_text, h.result_text, h.via, h.favorited, h.latency_ms
|
||||||
|
FROM history_fts f JOIN history h ON h.id = f.rowid
|
||||||
|
WHERE history_fts MATCH ?3",
|
||||||
|
);
|
||||||
|
if favorited_only {
|
||||||
|
sql.push_str(" AND h.favorited = 1");
|
||||||
|
}
|
||||||
|
sql.push_str(" ORDER BY h.ts DESC LIMIT ?1 OFFSET ?2");
|
||||||
|
} else {
|
||||||
|
sql.push_str(
|
||||||
|
"SELECT id, ts, from_lang, to_lang, engine_id, engine_name, source_text,
|
||||||
|
result_text, via, favorited, latency_ms FROM history WHERE ",
|
||||||
|
);
|
||||||
|
if favorited_only {
|
||||||
|
sql.push_str("favorited = 1 AND ");
|
||||||
|
}
|
||||||
|
// 无关键词时 pattern 为 NULL,`?3 IS NULL` 让条件恒真。
|
||||||
|
// 这样参数个数固定,避免「有/无 ?3」两种形态下绑定索引不一致。
|
||||||
|
// ESCAPE '\':SQLite 的 LIKE 默认没有转义符,不写这个子句
|
||||||
|
// escape_like 对 %/_ 的转义就是无效代码。
|
||||||
|
sql.push_str(
|
||||||
|
"(?3 IS NULL OR source_text LIKE ?3 ESCAPE '\\' OR result_text LIKE ?3 ESCAPE '\\')
|
||||||
|
ORDER BY ts DESC LIMIT ?1 OFFSET ?2",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let param3 = term.map(|t| {
|
||||||
|
if use_fts {
|
||||||
|
fts_phrase(t)
|
||||||
|
} else {
|
||||||
|
format!("%{}%", escape_like(t))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let conn = self.conn();
|
||||||
|
let mut stmt = conn.prepare(&sql).map_err(|e| format!("查询历史失败: {e}"))?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![limit, offset, param3], row_to_item)
|
||||||
|
.map_err(|e| format!("查询历史失败: {e}"))?;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
items.push(row.map_err(|e| format!("读取历史失败: {e}"))?);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: i64) -> Result<(), String> {
|
||||||
|
self.conn()
|
||||||
|
.execute("DELETE FROM history WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| format!("删除历史失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) -> Result<(), String> {
|
||||||
|
self.conn()
|
||||||
|
.execute("DELETE FROM history", [])
|
||||||
|
.map_err(|e| format!("清空历史失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_favorited(&self, id: i64, favorited: bool) -> Result<(), String> {
|
||||||
|
self.conn()
|
||||||
|
.execute(
|
||||||
|
"UPDATE history SET favorited = ?1 WHERE id = ?2",
|
||||||
|
params![favorited as i64, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("更新收藏失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 容量淘汰:超出上限时优先删最旧的非收藏记录。
|
||||||
|
pub fn prune_to_max(&self, max_items: i64) {
|
||||||
|
if max_items <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(conn) = self.conn.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = conn.execute(
|
||||||
|
"DELETE FROM history WHERE id IN (
|
||||||
|
SELECT id FROM history WHERE favorited = 0
|
||||||
|
ORDER BY ts DESC LIMIT -1 OFFSET ?1
|
||||||
|
)",
|
||||||
|
params![max_items],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_like(input: &str) -> String {
|
||||||
|
input.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把用户输入包成一个 FTS5 短语查询。
|
||||||
|
///
|
||||||
|
/// **必须包引号**:FTS5 的 MATCH 参数有自己的一套查询语法,`-`、`*`、`(`、`^`
|
||||||
|
/// 以及 `AND`/`OR`/`NOT` 都会被当作操作符——用户搜 `a-b` 会被解释成「含 a 但不含 b」,
|
||||||
|
/// 搜 `(` 之类则直接抛语法错误。整串加双引号后退化成「按字面顺序出现的短语」,
|
||||||
|
/// 与原本 LIKE 子串语义对齐;引号内的 `"` 用双写转义。
|
||||||
|
fn fts_phrase(term: &str) -> String {
|
||||||
|
format!("\"{}\"", term.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryItem> {
|
||||||
|
Ok(HistoryItem {
|
||||||
|
id: row.get(0)?,
|
||||||
|
ts: row.get(1)?,
|
||||||
|
from_lang: row.get(2)?,
|
||||||
|
to_lang: row.get(3)?,
|
||||||
|
engine_id: row.get(4)?,
|
||||||
|
engine_name: row.get(5)?,
|
||||||
|
source_text: row.get(6)?,
|
||||||
|
result_text: row.get(7)?,
|
||||||
|
via: row.get(8)?,
|
||||||
|
favorited: row.get::<_, i64>(9)? != 0,
|
||||||
|
latency_ms: row.get(10)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// 每个用例一个独立目录。`History` 持有连接,测试结束前无法删目录(Windows 会拒绝),
|
||||||
|
/// 因此这里只保证不互相踩,不留清理——落在系统临时目录里是可以接受的代价。
|
||||||
|
fn open(tag: &str) -> (History, PathBuf) {
|
||||||
|
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"thing-history-test-{}-{tag}-{n}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let history = History::new(&dir).expect("建库失败");
|
||||||
|
(history, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一组覆盖两种分词行为的样例:
|
||||||
|
/// 中日英混排、含 FTS 元字符(`-` `*` `"`)、含 LIKE 元字符(`%`)、含 FTS 操作符关键字。
|
||||||
|
const SOURCE_JA: &str = "日本語の翻訳を確認します";
|
||||||
|
const RESULT_JA: &str = "确认日本语翻译";
|
||||||
|
const SOURCE_EN: &str = "return the formatted date string";
|
||||||
|
const RESULT_EN: &str = "返回格式化后的日期字符串";
|
||||||
|
const SOURCE_JA2: &str = "すべての項目に入力をしてください";
|
||||||
|
const RESULT_JA2: &str = "请在所有项目中输入。";
|
||||||
|
const SOURCE_META: &str = "tail with \"quote\" and *star and a-b";
|
||||||
|
const SOURCE_PCT: &str = "100% done";
|
||||||
|
const SOURCE_KW: &str = "a AND b";
|
||||||
|
|
||||||
|
fn seed(h: &History) {
|
||||||
|
let rows = [
|
||||||
|
(SOURCE_JA, RESULT_JA, "manual"),
|
||||||
|
(SOURCE_EN, RESULT_EN, "clipboard"),
|
||||||
|
(SOURCE_JA2, RESULT_JA2, "selection"),
|
||||||
|
(SOURCE_META, "x", "manual"),
|
||||||
|
(SOURCE_PCT, "y", "manual"),
|
||||||
|
(SOURCE_KW, "z", "manual"),
|
||||||
|
];
|
||||||
|
for (i, (src, res, via)) in rows.iter().enumerate() {
|
||||||
|
h.record("auto", "zh-Hans", "mock", "Mock", src, res, via, i as i64)
|
||||||
|
.expect("写入失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用原生 LIKE 算出的基准集合,作为 FTS 路径的正确性参照。
|
||||||
|
fn like_baseline(dir: &Path, term: &str, favorited_only: bool) -> Vec<i64> {
|
||||||
|
let conn = Connection::open(dir.join("history.db")).unwrap();
|
||||||
|
let pattern = format!("%{}%", escape_like(term));
|
||||||
|
let sql = if favorited_only {
|
||||||
|
"SELECT id FROM history WHERE favorited = 1
|
||||||
|
AND (source_text LIKE ?1 ESCAPE '\\' OR result_text LIKE ?1 ESCAPE '\\')"
|
||||||
|
} else {
|
||||||
|
"SELECT id FROM history WHERE
|
||||||
|
source_text LIKE ?1 ESCAPE '\\' OR result_text LIKE ?1 ESCAPE '\\'"
|
||||||
|
};
|
||||||
|
let mut stmt = conn.prepare(sql).unwrap();
|
||||||
|
let rows = stmt.query_map(params![pattern], |r| r.get::<_, i64>(0)).unwrap();
|
||||||
|
rows.map(|r| r.unwrap()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按集合比较,忽略顺序:`record()` 用 wall clock 打时间戳,同一用例内的记录
|
||||||
|
/// ts 可能相同,`ORDER BY ts DESC` 的先后不稳定,比顺序会随机失败。
|
||||||
|
fn sorted_ids(h: &History, term: &str, favorited_only: bool) -> Vec<i64> {
|
||||||
|
let mut ids: Vec<i64> = h
|
||||||
|
.list(0, 100, Some(term), favorited_only)
|
||||||
|
.expect("查询失败")
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id)
|
||||||
|
.collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted(v: Vec<i64>) -> Vec<i64> {
|
||||||
|
let mut v = v;
|
||||||
|
v.sort_unstable();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两种 SQL 形态必须给出**完全一致**的结果。
|
||||||
|
///
|
||||||
|
/// 这条测试的核心是钉住长度分流:trigram 索引里没有短于 3 字符的片段,
|
||||||
|
/// 对 1~2 字的关键词 `MATCH` 不报错、只返回空集。少了回退分支,中文两字词
|
||||||
|
/// (最常见的一类查询)就会「条件明明对却搜不到」。
|
||||||
|
#[test]
|
||||||
|
fn fts_and_like_paths_agree_across_query_lengths() {
|
||||||
|
let (h, dir) = open("paths-agree");
|
||||||
|
seed(&h);
|
||||||
|
let ids: Vec<i64> = h.list(0, 100, None, false).unwrap().iter().map(|i| i.id).collect();
|
||||||
|
assert_eq!(ids.len(), 6, "样例数据应为 6 条(去重键各不相同)");
|
||||||
|
h.set_favorited(ids[1], true).unwrap();
|
||||||
|
|
||||||
|
let terms = [
|
||||||
|
"确", // 1 字 → LIKE 回退
|
||||||
|
"确认", // 2 字 → LIKE 回退(中文里最常见,最容易踩坑的长度)
|
||||||
|
"fo", // 2 字符 ASCII → LIKE 回退
|
||||||
|
"确认日", // 3 字 → FTS
|
||||||
|
"日本语翻译", // 5 字 → FTS
|
||||||
|
"form", // 4 字符 → FTS,且是 formatted 的子串
|
||||||
|
"the formatted",
|
||||||
|
"zzz", // 无命中
|
||||||
|
"100%", // LIKE 元字符:两条路径都必须按字面处理
|
||||||
|
"a-b", // FTS 里 `-` 是操作符,必须被短语引号中和
|
||||||
|
"*star",
|
||||||
|
"with \"quote\"",
|
||||||
|
"AND", // FTS 关键字,必须按字面匹配而不是当运算符
|
||||||
|
"(((",
|
||||||
|
];
|
||||||
|
for term in terms {
|
||||||
|
for favorited_only in [false, true] {
|
||||||
|
assert_eq!(
|
||||||
|
sorted_ids(&h, term, favorited_only),
|
||||||
|
sorted(like_baseline(&dir, term, favorited_only)),
|
||||||
|
"关键词 {term:?}(favoritedOnly={favorited_only})两条路径结果不一致"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 外部内容表不会自动感知主表变化,全靠触发器。漏一个就会让索引与正文静默错位
|
||||||
|
/// ——表现是「刚翻过的句子搜不到」或「已删掉的记录还能搜出来」,都很难归因。
|
||||||
|
#[test]
|
||||||
|
fn triggers_keep_fts_in_sync_with_upsert_and_delete() {
|
||||||
|
let (h, _dir) = open("trigger-sync");
|
||||||
|
h.record("ja", "zh-Hans", "mock", "Mock", SOURCE_JA, RESULT_JA, "manual", 1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sorted_ids(&h, "日本语翻译", false).len(), 1, "新记录应可搜到");
|
||||||
|
|
||||||
|
// 去重键命中 → 走 ON CONFLICT DO UPDATE,应触发 update 触发器
|
||||||
|
h.record("ja", "zh-Hans", "mock", "Mock", SOURCE_JA, "换了译文的说法", "manual", 2)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(h.list(0, 100, None, false).unwrap().len(), 1, "应仍是同一条记录");
|
||||||
|
assert_eq!(sorted_ids(&h, "换了译文的说法", false).len(), 1, "更新后新译文应可搜到");
|
||||||
|
assert!(sorted_ids(&h, "日本语翻译", false).is_empty(), "更新后旧译文不应再命中");
|
||||||
|
|
||||||
|
let id = h.list(0, 100, None, false).unwrap()[0].id;
|
||||||
|
h.delete(id).unwrap();
|
||||||
|
assert!(sorted_ids(&h, "换了译文的说法", false).is_empty(), "删除后不应再命中");
|
||||||
|
|
||||||
|
h.record("ja", "zh-Hans", "mock", "Mock", SOURCE_JA, RESULT_JA, "manual", 3)
|
||||||
|
.unwrap();
|
||||||
|
h.clear().unwrap();
|
||||||
|
assert!(h.list(0, 100, None, false).unwrap().is_empty());
|
||||||
|
assert!(sorted_ids(&h, "换了译文的说法", false).is_empty(), "清空后索引应为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FTS 索引必须与主表行数一致。用 FTS5 自带的完整性检查暴露静默错位。
|
||||||
|
#[test]
|
||||||
|
fn fts_index_passes_integrity_check() {
|
||||||
|
let (h, _dir) = open("integrity");
|
||||||
|
seed(&h);
|
||||||
|
let conn = h.conn();
|
||||||
|
conn.execute("INSERT INTO history_fts(history_fts) VALUES('integrity-check')", [])
|
||||||
|
.expect("FTS 索引与主表不一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏条目不参与容量淘汰(既有约定,顺手一起守住)。
|
||||||
|
#[test]
|
||||||
|
fn prune_drops_oldest_unfavorited_only() {
|
||||||
|
let (h, _dir) = open("prune");
|
||||||
|
for i in 0..6 {
|
||||||
|
// 原文必须各不相同:去重键含 source_text,同一段文字只会留一条
|
||||||
|
let src = format!("{SOURCE_JA}{i}");
|
||||||
|
h.record("auto", "zh-Hans", "mock", "Mock", &src, RESULT_JA, "manual", i)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let ids: Vec<i64> = h.list(0, 100, None, false).unwrap().iter().map(|i| i.id).collect();
|
||||||
|
assert_eq!(ids.len(), 6);
|
||||||
|
let favorite = ids[2];
|
||||||
|
h.set_favorited(favorite, true).unwrap();
|
||||||
|
|
||||||
|
h.prune_to_max(3);
|
||||||
|
|
||||||
|
let left: Vec<i64> = h.list(0, 100, None, false).unwrap().iter().map(|i| i.id).collect();
|
||||||
|
assert!(left.contains(&favorite), "收藏条目被淘汰了");
|
||||||
|
assert_eq!(left.len(), 4, "上限 3 之外只该多留那条收藏,实际剩 {left:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结构版本变化时整库重建。这条是「现在允许丢数据」这个前提的守卫:
|
||||||
|
/// 一旦有人把 SCHEMA_VERSION 忘了改,旧库会带着不兼容的结构跑下去。
|
||||||
|
#[test]
|
||||||
|
fn stale_schema_is_dropped_and_rebuilt() {
|
||||||
|
let (h, dir) = open("schema-reset");
|
||||||
|
seed(&h);
|
||||||
|
assert_eq!(h.list(0, 100, None, false).unwrap().len(), 6);
|
||||||
|
drop(h);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(dir.join("history.db")).unwrap();
|
||||||
|
conn.execute("INSERT INTO history(ts, from_lang, to_lang, engine_id, engine_name, source_text, result_text, via, favorited, latency_ms)
|
||||||
|
VALUES (1,'auto','zh-Hans','mock','Mock','stale row','stale row','manual',0,0)", []).unwrap();
|
||||||
|
conn.execute_batch("PRAGMA user_version = 1;").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rebuilt = History::new(&dir).expect("重建失败");
|
||||||
|
assert!(
|
||||||
|
rebuilt.list(0, 100, None, false).unwrap().is_empty(),
|
||||||
|
"版本不匹配时旧数据应被清掉"
|
||||||
|
);
|
||||||
|
assert!(sorted_ids(&rebuilt, "stale row", false).is_empty(), "重建后不应残留旧索引");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 短语化必须中和 FTS5 的查询语法,否则用户搜 `a-b` 会被解释成「含 a 但不含 b」,
|
||||||
|
/// 搜 `(` 之类则直接抛语法错误。
|
||||||
|
#[test]
|
||||||
|
fn fts_phrase_quotes_and_escapes() {
|
||||||
|
assert_eq!(fts_phrase("a-b"), "\"a-b\"");
|
||||||
|
assert_eq!(fts_phrase("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||||
|
assert_eq!(fts_phrase("("), "\"(\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
//! 翻译模块:多源翻译 + AI 翻译 + 划词翻译 + 截图翻译。
|
||||||
|
//!
|
||||||
|
//! 架构(详见仓库根目录 `TRANSLATE_MODULE_PLAN.md`):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 前端(主面板 / 划词悬浮窗 / 截图选区)
|
||||||
|
//! ↓ Tauri IPC
|
||||||
|
//! commands.rs 命令层(薄:参数整形 + 交给 manager)
|
||||||
|
//! ↓
|
||||||
|
//! TranslateManager(本文件):设置读写 / 引擎装配 / 代理判定 / 自动降级编排
|
||||||
|
//! ↓
|
||||||
|
//! engines/ 引擎实现(ai = OpenAI 兼容;free = libretranslate / mymemory)
|
||||||
|
//! capture/ 取词(P1:Ctrl+C 兼容路径)
|
||||||
|
//! popup.rs 取词结果的非激活悬浮窗
|
||||||
|
//! ↓ reqwest
|
||||||
|
//! DeepSeek / OpenAI 兼容端点 / LibreTranslate / MyMemory
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 三条贯穿全模块的约束:
|
||||||
|
//! 1. **网络请求只在 Rust 侧发生**。生产构建的 CSP 只允许 `ipc:` 连接,前端直连外部
|
||||||
|
//! API 会被静默拦截;且密钥若进入 WebView,等于把凭据暴露给了页面上下文。
|
||||||
|
//! 2. **引擎实例按请求即时构造**,不缓存。引擎是有状态的(密钥、模型、超时),
|
||||||
|
//! 而设置页改完就该立刻生效——缓存实例反而要额外处理失效,得不偿失。
|
||||||
|
//! 3. **代理只在这里判定一次**。reqwest 的代理必须在建 Client 时指定、不能按请求覆盖,
|
||||||
|
//! 因此「直连还是走代理」的决策收敛到 [`TranslateManager::select_client`],
|
||||||
|
//! 各引擎一律拿现成的客户端。
|
||||||
|
|
||||||
|
mod capture;
|
||||||
|
mod commands;
|
||||||
|
mod engines;
|
||||||
|
mod history;
|
||||||
|
mod ocr;
|
||||||
|
mod popup;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
pub use commands::{
|
||||||
|
translate_abort, translate_apply_shortcuts, translate_copy_text, translate_engine_delete,
|
||||||
|
translate_engine_models, translate_engine_save, translate_engine_test_config,
|
||||||
|
translate_engines_list,
|
||||||
|
translate_get_settings, translate_history_clear, translate_history_delete,
|
||||||
|
translate_history_list, translate_history_set_favorited, translate_ocr_languages,
|
||||||
|
translate_paste_back, translate_popup_edit_mode, translate_popup_hide,
|
||||||
|
translate_popup_prefs_set, translate_popup_ready, translate_popup_resize,
|
||||||
|
translate_popup_set_pinned, translate_preview_popup, translate_run, translate_save_settings,
|
||||||
|
translate_screenshot_region, translate_secret_clear, translate_secret_set,
|
||||||
|
translate_stream_start,
|
||||||
|
};
|
||||||
|
pub use engines::{TranslateEngine, TranslateError, TranslateResult};
|
||||||
|
// 只再导出本模块内部(mod.rs / commands.rs / popup.rs)实际用到的类型。其余类型仍保留在
|
||||||
|
// `engines::` / `settings::` 下,等真正用到时再提升到此处——提前摆出一堆无人消费的再导出,
|
||||||
|
// 只会让「谁在用」更难判断。
|
||||||
|
pub use settings::TranslateSettings;
|
||||||
|
|
||||||
|
use engines::EngineRequest;
|
||||||
|
use settings::TranslateEngineConfig;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// 设置内存缓存有效期。设置页存在「读一次、改一处、再读」的高频往返,
|
||||||
|
/// 不缓存会反复读盘;缓存过长又会让外部改动不可见,500ms 与音乐模块保持一致。
|
||||||
|
const SETTINGS_CACHE_TTL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// 代理可用性的缓存有效期。控制端探测是一次本机 HTTP 请求(通常 1ms 内拒绝/返回),
|
||||||
|
/// 但设置页刷一次引擎列表就要探测一次,加个短缓存避免无谓往返。
|
||||||
|
const PROXY_CACHE_TTL: Duration = Duration::from_millis(5000);
|
||||||
|
|
||||||
|
/// 引擎 API Key 的凭据键前缀(键名格式:`translate-engine-<engineId>`)。
|
||||||
|
const ENGINE_KEY_PREFIX: &str = "translate-engine-";
|
||||||
|
|
||||||
|
/// 某引擎实例的凭据键。
|
||||||
|
pub fn engine_secret_key(engine_id: &str) -> String {
|
||||||
|
format!("{ENGINE_KEY_PREFIX}{engine_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动时按设置注册全局快捷键并预创建取词悬浮窗。
|
||||||
|
/// 失败只记日志、不阻断启动——快捷键被别的程序占用不该让应用起不来。
|
||||||
|
pub fn init_on_launch(app: &tauri::AppHandle) {
|
||||||
|
if let Err(e) = popup::apply_shortcuts(app) {
|
||||||
|
crate::logger::log_warn("translate", &format!("启动时注册取词快捷键失败: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取某引擎实例的 API Key(未配置或读取失败 → 空串)。
|
||||||
|
pub fn engine_api_key(engine_id: &str) -> String {
|
||||||
|
if engine_id.trim().is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
crate::secrets::read_or_empty(&engine_secret_key(engine_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsCache {
|
||||||
|
read_at: Instant,
|
||||||
|
settings: TranslateSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 翻译模块管理器(Tauri State)。
|
||||||
|
pub struct TranslateManager {
|
||||||
|
/// 应用数据目录(proxy 模块的配置也在这一层,代理判定需要)
|
||||||
|
data_dir: PathBuf,
|
||||||
|
/// 模块自身目录:{app_data_dir}/translate
|
||||||
|
root: PathBuf,
|
||||||
|
/// 直连客户端
|
||||||
|
client: reqwest::Client,
|
||||||
|
cache: Mutex<Option<SettingsCache>>,
|
||||||
|
/// 代理地址解析缓存:`(解析时刻, 结果)`;结果为 None 表示已开启但不可用
|
||||||
|
proxy_cache: Mutex<Option<(Instant, Option<String>)>>,
|
||||||
|
/// 带代理的客户端缓存:`(代理地址, 客户端)`。与地址一一对应,地址变了就重建。
|
||||||
|
proxied_client: Mutex<Option<(String, reqwest::Client)>>,
|
||||||
|
/// 翻译历史。打开失败时为 None:历史是锦上添花,不值得为它让整个模块不可用。
|
||||||
|
history: Option<std::sync::Arc<history::History>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranslateManager {
|
||||||
|
pub fn new(app_data_dir: PathBuf) -> Self {
|
||||||
|
let root = app_data_dir.join("translate");
|
||||||
|
std::fs::create_dir_all(&root).ok();
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
// 兜底超时:单个请求还会按引擎配置设置更精确的超时
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
let history = match history::History::new(&root) {
|
||||||
|
Ok(h) => Some(std::sync::Arc::new(h)),
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_warn("translate", &format!("翻译历史不可用: {e}"));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
data_dir: app_data_dir,
|
||||||
|
root,
|
||||||
|
client,
|
||||||
|
cache: Mutex::new(None),
|
||||||
|
proxy_cache: Mutex::new(None),
|
||||||
|
proxied_client: Mutex::new(None),
|
||||||
|
history,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_path(&self) -> PathBuf {
|
||||||
|
self.root.join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 设置 =====
|
||||||
|
|
||||||
|
/// 读取设置(带短时缓存;文件缺失/损坏回退默认值;自愈结果落盘)。
|
||||||
|
pub fn load_settings(&self) -> TranslateSettings {
|
||||||
|
if let Ok(cache) = self.cache.lock() {
|
||||||
|
if let Some(entry) = cache.as_ref() {
|
||||||
|
if entry.read_at.elapsed() < SETTINGS_CACHE_TTL {
|
||||||
|
return entry.settings.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 文件缺失或损坏一律回退默认值:翻译是可随时重建的配置,
|
||||||
|
// 不值得为「读不懂的旧文件」让整个模块不可用。
|
||||||
|
let mut settings = std::fs::read_to_string(self.settings_path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<TranslateSettings>(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let healed = settings.heal();
|
||||||
|
if let Ok(mut cache) = self.cache.lock() {
|
||||||
|
*cache = Some(SettingsCache {
|
||||||
|
read_at: Instant::now(),
|
||||||
|
settings: settings.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if healed {
|
||||||
|
if let Err(e) = self.save_settings(&settings) {
|
||||||
|
crate::logger::log_error("translate", &format!("设置自愈落盘失败: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存设置并刷新缓存。
|
||||||
|
pub fn save_settings(&self, settings: &TranslateSettings) -> Result<(), String> {
|
||||||
|
let json = serde_json::to_string_pretty(settings)
|
||||||
|
.map_err(|e| format!("序列化翻译设置失败: {e}"))?;
|
||||||
|
std::fs::write(self.settings_path(), json).map_err(|e| format!("写入翻译设置失败: {e}"))?;
|
||||||
|
if let Ok(mut cache) = self.cache.lock() {
|
||||||
|
*cache = Some(SettingsCache {
|
||||||
|
read_at: Instant::now(),
|
||||||
|
settings: settings.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 代理 =====
|
||||||
|
|
||||||
|
/// 读代理模块(mihomo)配置:`{app_data_dir}/proxy/settings.json` 的 mixedPort。
|
||||||
|
/// 返回 `(mixedPort, externalController)`;缺失或非法返回 None。
|
||||||
|
///
|
||||||
|
/// 这里刻意**只读文件不依赖 MihomoManager 状态**:翻译模块不该因为代理模块被禁用
|
||||||
|
/// 就拿不到端口;端口是否可用由下面的控制端探测回答。
|
||||||
|
fn read_mixed_port(&self) -> Option<(u16, String)> {
|
||||||
|
let path = self.data_dir.join("proxy").join("settings.json");
|
||||||
|
let raw = std::fs::read_to_string(path).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||||
|
let port = value.get("mixedPort").and_then(|v| v.as_u64())?;
|
||||||
|
if port == 0 || port > 65535 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let controller = value
|
||||||
|
.get("externalController")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("127.0.0.1:9090")
|
||||||
|
.to_string();
|
||||||
|
Some((port as u16, controller))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测 mihomo 控制端是否在线。
|
||||||
|
///
|
||||||
|
/// 判定标准是「能建立连接」而不是「返回 200」:控制端设了 secret 时会返回 401,
|
||||||
|
/// 但那恰恰说明内核在运行、代理端口可用。只有连接失败才算离线。
|
||||||
|
async fn probe_controller(&self, controller: &str) -> bool {
|
||||||
|
let base = if controller.starts_with("http://") || controller.starts_with("https://") {
|
||||||
|
controller.to_string()
|
||||||
|
} else {
|
||||||
|
format!("http://{controller}")
|
||||||
|
};
|
||||||
|
let url = format!("{}/version", base.trim_end_matches('/'));
|
||||||
|
self.client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(Duration::from_millis(800))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析当前应使用的代理地址(带 TTL 缓存)。未开启开关时返回 None。
|
||||||
|
async fn resolve_proxy_url(&self) -> Option<String> {
|
||||||
|
if !self.load_settings().use_proxy {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Ok(cache) = self.proxy_cache.lock() {
|
||||||
|
if let Some((at, url)) = cache.as_ref() {
|
||||||
|
if at.elapsed() < PROXY_CACHE_TTL {
|
||||||
|
return url.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = match self.read_mixed_port() {
|
||||||
|
Some((port, controller)) => {
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
if self.probe_controller(&controller).await {
|
||||||
|
Some(url)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut cache) = self.proxy_cache.lock() {
|
||||||
|
let previous = cache.as_ref().and_then(|(_, u)| u.clone());
|
||||||
|
let changed = previous != resolved;
|
||||||
|
*cache = Some((Instant::now(), resolved.clone()));
|
||||||
|
// 地址变了(或可用性变了)就丢弃旧客户端,否则会继续用已失效的代理
|
||||||
|
if changed {
|
||||||
|
if let Ok(mut client) = self.proxied_client.lock() {
|
||||||
|
*client = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择本次请求使用的 HTTP 客户端:开了代理且可用 → 走代理,否则直连。
|
||||||
|
/// 代理不可用时**静默回退直连**:让请求自己去失败,比在这里造一个错误更接近真相
|
||||||
|
/// (也许代理确实不通但目标站恰好可达)。
|
||||||
|
async fn select_client(&self) -> reqwest::Client {
|
||||||
|
let Some(url) = self.resolve_proxy_url().await else {
|
||||||
|
return self.client.clone();
|
||||||
|
};
|
||||||
|
if let Ok(cache) = self.proxied_client.lock() {
|
||||||
|
if let Some((cached_url, client)) = cache.as_ref() {
|
||||||
|
if *cached_url == url {
|
||||||
|
return client.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let built = reqwest::Proxy::all(&url).ok().and_then(|proxy| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.proxy(proxy)
|
||||||
|
.build()
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
match built {
|
||||||
|
Some(client) => {
|
||||||
|
if let Ok(mut cache) = self.proxied_client.lock() {
|
||||||
|
*cache = Some((url, client.clone()));
|
||||||
|
}
|
||||||
|
client
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
crate::logger::log_warn("translate", "构造带代理的 HTTP 客户端失败,本次回退直连");
|
||||||
|
self.client.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 引擎 =====
|
||||||
|
|
||||||
|
/// 按配置装配引擎实例(异步:需要先决定走不走代理)。
|
||||||
|
pub async fn build(
|
||||||
|
&self,
|
||||||
|
cfg: &TranslateEngineConfig,
|
||||||
|
) -> Result<Box<dyn TranslateEngine>, TranslateError> {
|
||||||
|
let templates = self.load_settings().prompt_templates;
|
||||||
|
let client = self.select_client().await;
|
||||||
|
engines::build_engine(cfg, &templates, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析本次请求实际要尝试的引擎序列。
|
||||||
|
///
|
||||||
|
/// - 显式指定了 `engineId`:只用它(用户要的就是「这个源」)——请求失败不做降级,
|
||||||
|
/// 否则「指定了 A 却拿到 B 的译文」比报错更令人困惑。
|
||||||
|
/// - `auto` / 未指定:开启降级时按优先级依次尝试;关闭降级时只用默认引擎。
|
||||||
|
pub fn resolve_candidates(
|
||||||
|
&self,
|
||||||
|
engine_id: Option<&str>,
|
||||||
|
) -> Result<Vec<TranslateEngineConfig>, TranslateError> {
|
||||||
|
let settings = self.load_settings();
|
||||||
|
let explicit = engine_id
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty() && *s != "auto");
|
||||||
|
|
||||||
|
if let Some(id) = explicit {
|
||||||
|
return settings
|
||||||
|
.engine(id)
|
||||||
|
.cloned()
|
||||||
|
.map(|cfg| vec![cfg])
|
||||||
|
.ok_or_else(|| TranslateError::config(format!("找不到引擎实例「{id}」")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.auto_fallback {
|
||||||
|
let list: Vec<TranslateEngineConfig> =
|
||||||
|
settings.auto_candidates().into_iter().cloned().collect();
|
||||||
|
if list.is_empty() {
|
||||||
|
return Err(TranslateError::config(
|
||||||
|
"没有已启用的翻译引擎,请先在翻译设置中启用至少一个",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未开启降级:只尝试默认引擎;默认引擎失效时回落到优先级最高的已启用实例
|
||||||
|
let preferred = settings
|
||||||
|
.engine(&settings.default_engine_id)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| settings.auto_candidates().first().map(|c| (*c).clone()));
|
||||||
|
preferred.map(|cfg| vec![cfg]).ok_or_else(|| {
|
||||||
|
TranslateError::config("没有已启用的翻译引擎,请先在翻译设置中启用至少一个")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行一次翻译(含自动降级)。
|
||||||
|
///
|
||||||
|
/// `record`:是否写入历史。正常翻译为 true;**多引擎对比必须传 false**——
|
||||||
|
/// 对比一次产生 N 条结果,全部入库只会污染记录。历史是否记录由此参数
|
||||||
|
/// 显式决定,而不是靠调用方绕开本方法(绕开会让「哪些入口记历史」无从查起)。
|
||||||
|
pub async fn run(
|
||||||
|
&self,
|
||||||
|
req: EngineRequest,
|
||||||
|
engine_id: Option<&str>,
|
||||||
|
record: bool,
|
||||||
|
) -> Result<TranslateResult, TranslateError> {
|
||||||
|
let candidates = self.resolve_candidates(engine_id)?;
|
||||||
|
let mut last_error: Option<TranslateError> = None;
|
||||||
|
|
||||||
|
for cfg in candidates {
|
||||||
|
let engine = match self.build(&cfg).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
// 装配失败(如未接入的引擎类型)继续尝试下一个候选,
|
||||||
|
// 让「自动」模式真正具备容错意义
|
||||||
|
last_error = Some(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match engine.translate(&req).await {
|
||||||
|
Ok(result) => {
|
||||||
|
if record {
|
||||||
|
self.record_history(&req, &result, &result.engine_id);
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_warn(
|
||||||
|
"translate",
|
||||||
|
&format!(
|
||||||
|
"引擎「{}」翻译失败:{}(kind={:?})",
|
||||||
|
engine.name(),
|
||||||
|
e.message,
|
||||||
|
e.kind
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 输入为空、语言对不支持这类错误换源也没用,直接返回首个错误
|
||||||
|
if !e.retryable() {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| {
|
||||||
|
TranslateError::config("没有可用的翻译引擎,请先在翻译设置中启用至少一个")
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 历史库(打开失败时为 None)。`Arc` 便于流式转发任务独立持有。
|
||||||
|
pub(crate) fn history(&self) -> Option<std::sync::Arc<history::History>> {
|
||||||
|
self.history.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录一次成功翻译(失败不记:历史是「翻过的东西」,不是「试过的东西」)。
|
||||||
|
/// 历史开关关闭或库不可用时静默跳过。
|
||||||
|
fn record_history(&self, req: &EngineRequest, result: &TranslateResult, engine_id: &str) {
|
||||||
|
let settings = self.load_settings();
|
||||||
|
if !settings.history.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(history) = self.history.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// 截图(视觉直译)没有原文文本,用占位说明来源
|
||||||
|
let source = if req.text.trim().is_empty() {
|
||||||
|
"(屏幕截图)"
|
||||||
|
} else {
|
||||||
|
req.text.as_str()
|
||||||
|
};
|
||||||
|
if let Err(e) = history.record(
|
||||||
|
&req.from,
|
||||||
|
&req.to,
|
||||||
|
engine_id,
|
||||||
|
&result.engine_name,
|
||||||
|
source,
|
||||||
|
&result.text,
|
||||||
|
&req.via,
|
||||||
|
result.latency_ms as i64,
|
||||||
|
) {
|
||||||
|
crate::logger::log_warn("translate", &format!("记录历史失败: {e}"));
|
||||||
|
}
|
||||||
|
history.prune_to_max(settings.history.max_items as i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! OCR:把图变字。
|
||||||
|
//!
|
||||||
|
//! 目前只有 Windows 本地识别(`Windows.Media.Ocr`)。抽象成独立模块的原因是
|
||||||
|
//! **截图翻译有两条并行的取字路径**:本地 OCR(离线、零成本、图片不外发)与
|
||||||
|
//! 视觉大模型直译(识别 + 翻译一步完成,对表格/菜单这类复杂排版明显更好)。
|
||||||
|
//! 两条路径对上层暴露相同的入口签名,`screenshot.ocrMode` 决定走哪条。
|
||||||
|
//!
|
||||||
|
//! 关于本地 OCR 的三个已知限制(都会在错误信息里引导,而不是含糊报错):
|
||||||
|
//! - 依赖系统 OCR 语言包:英文永远可用,中/日/韩等需要系统安装对应语言包;
|
||||||
|
//! - 印刷体效果好,竖排、艺术字、低对比度截图效果差;
|
||||||
|
//! - `Windows.Media.Ocr` 要求输入为 BGRA8 格式,其它格式需先转换。
|
||||||
|
|
||||||
|
pub mod windows_ocr;
|
||||||
|
|
||||||
|
pub use windows_ocr::{available_languages, ocr_png};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Windows.Media.Ocr 本地识别。
|
||||||
|
//!
|
||||||
|
//! 实现要点(三个坑都已规避):
|
||||||
|
//! 1. **不落盘**。走「`image` 解码 PNG → RGBA 转 BGRA → `CryptographicBuffer` 构造
|
||||||
|
//! `IBuffer` → `SoftwareBitmap::CreateCopyFromBuffer`」的纯内存路径。
|
||||||
|
//! 0.52 版 windows crate 的 `RandomAccessStreamReference` 没有 `CreateFromByteArray`,
|
||||||
|
//! 走 BitmapDecoder 的流路径既要临时流又要二次解码,因此放弃。
|
||||||
|
//! 2. **像素格式**。`RecognizeAsync` 要求 BGRA8,而 PNG 解出来常是 Rgba8,
|
||||||
|
//! 转换在这里显式完成(同时交换 R/B 通道),不做这一步识别会失败。
|
||||||
|
//! 3. **`IAsyncOperation::get()` 会阻塞当前线程**。调用方必须在阻塞线程池执行,
|
||||||
|
//! 并初始化 MTA——在 STA 上 `get()` 有死锁风险。
|
||||||
|
//!
|
||||||
|
//! 若干方法(`Lines` / `AvailableRecognizerLanguages`)在 windows crate 里按 feature
|
||||||
|
//! 裁剪(返回 `IVectorView` 需要 `Foundation_Collections`),缺 feature 时表现为
|
||||||
|
//! 「方法不存在」而不是链接错误,排查时先看 Cargo.toml。
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use specta::Type;
|
||||||
|
use windows::Globalization::Language;
|
||||||
|
use windows::Graphics::Imaging::{BitmapPixelFormat, SoftwareBitmap};
|
||||||
|
use windows::Media::Ocr::OcrEngine;
|
||||||
|
use windows::Security::Cryptography::CryptographicBuffer;
|
||||||
|
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
|
||||||
|
|
||||||
|
use super::super::engines::{ErrorKind, TranslateError};
|
||||||
|
|
||||||
|
/// 一行识别结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OcrLine {
|
||||||
|
pub text: String,
|
||||||
|
/// 行边界框(相对输入图像的物理像素)。多个词的矩形取并集。
|
||||||
|
/// 一并返回是为了给「译文叠加在原文位置上」留出能力——这是截图翻译体验质变的前提。
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次识别的完整结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OcrResult {
|
||||||
|
pub lines: Vec<OcrLine>,
|
||||||
|
/// 实际使用的识别语言(BCP-47)
|
||||||
|
pub lang: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对 PNG 图像执行本地 OCR。
|
||||||
|
///
|
||||||
|
/// `lang`:`None` 或 "auto" 跟随用户配置的语言,否则用指定的 BCP-47 标签。
|
||||||
|
pub fn ocr_png(png: &[u8], lang: Option<&str>) -> Result<OcrResult, TranslateError> {
|
||||||
|
// MTA:`get()` 阻塞等待需要正确初始化的公寓;已初始化(S_FALSE)与
|
||||||
|
// 变更模式(RPC_E_CHANGED_MODE)都忽略——后者说明调用方已是 STA。
|
||||||
|
unsafe {
|
||||||
|
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = match lang.map(str::trim).filter(|s| !s.is_empty() && *s != "auto") {
|
||||||
|
Some(tag) => create_engine_for(tag)?,
|
||||||
|
None => match OcrEngine::TryCreateFromUserProfileLanguages() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(missing_language_pack(
|
||||||
|
"未检测到可用的 OCR 引擎",
|
||||||
|
"请在系统「设置 → 时间和语言 → 语言和区域」中添加语言并勾选「文本识别」",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let used_tag = engine
|
||||||
|
.RecognizerLanguage()
|
||||||
|
.ok()
|
||||||
|
.and_then(|l| l.LanguageTag().ok())
|
||||||
|
.map(|t| t.to_string_lossy())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let bitmap = bitmap_from_png(png)?;
|
||||||
|
let recognized = match engine.RecognizeAsync(&bitmap) {
|
||||||
|
Ok(op) => match op.get() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => return Err(TranslateError::new(ErrorKind::Unknown, format!("识别失败: {e}"))),
|
||||||
|
},
|
||||||
|
Err(e) => return Err(TranslateError::new(ErrorKind::Unknown, format!("识别失败: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if let Ok(view) = recognized.Lines() {
|
||||||
|
let count = view.Size().unwrap_or(0);
|
||||||
|
for i in 0..count {
|
||||||
|
let Ok(line) = view.GetAt(i) else { continue };
|
||||||
|
let Ok(text) = line.Text() else { continue };
|
||||||
|
let text = text.to_string_lossy();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, 0.0_f64, 0.0_f64);
|
||||||
|
if let Ok(words) = line.Words() {
|
||||||
|
let wcount = words.Size().unwrap_or(0);
|
||||||
|
for j in 0..wcount {
|
||||||
|
let Ok(word) = words.GetAt(j) else { continue };
|
||||||
|
let Ok(rect) = word.BoundingRect() else { continue };
|
||||||
|
x0 = x0.min(rect.X as f64);
|
||||||
|
y0 = y0.min(rect.Y as f64);
|
||||||
|
x1 = x1.max((rect.X + rect.Width) as f64);
|
||||||
|
y1 = y1.max((rect.Y + rect.Height) as f64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (x, y, width, height) = if x1 > x0 && y1 > y0 {
|
||||||
|
(x0, y0, x1 - x0, y1 - y0)
|
||||||
|
} else {
|
||||||
|
(0.0, 0.0, 0.0, 0.0)
|
||||||
|
};
|
||||||
|
lines.push(OcrLine {
|
||||||
|
text: text.trim().to_string(),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OcrResult {
|
||||||
|
lines,
|
||||||
|
lang: used_tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出系统当前可用的 OCR 语言(BCP-47 标签)。
|
||||||
|
/// 设置页用它展示「哪些语言能识别、哪些需要先装语言包」,而不是让用户撞一次错才知道。
|
||||||
|
pub fn available_languages() -> Result<Vec<String>, TranslateError> {
|
||||||
|
unsafe {
|
||||||
|
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||||
|
}
|
||||||
|
let view = OcrEngine::AvailableRecognizerLanguages()
|
||||||
|
.map_err(|e| TranslateError::parse(format!("读取系统 OCR 语言失败: {e}")))?;
|
||||||
|
let count = view.Size().unwrap_or(0);
|
||||||
|
let mut tags = Vec::with_capacity(count as usize);
|
||||||
|
for i in 0..count {
|
||||||
|
if let Ok(lang) = view.GetAt(i) {
|
||||||
|
if let Ok(tag) = lang.LanguageTag() {
|
||||||
|
let tag = tag.to_string_lossy();
|
||||||
|
if !tag.is_empty() {
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
|
Ok(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PNG → BGRA8 `SoftwareBitmap`(纯内存,不落盘)。
|
||||||
|
fn bitmap_from_png(png: &[u8]) -> Result<SoftwareBitmap, TranslateError> {
|
||||||
|
let img = image::load_from_memory(png)
|
||||||
|
.map_err(|e| TranslateError::parse(format!("解码截图失败: {e}")))?;
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let (width, height) = rgba.dimensions();
|
||||||
|
// OCR 引擎要求 BGRA8:RGBA 与 BGRA 只差 R/B 两个通道,就地交换
|
||||||
|
let mut bgra = rgba.into_raw();
|
||||||
|
for px in bgra.chunks_exact_mut(4) {
|
||||||
|
px.swap(0, 2);
|
||||||
|
}
|
||||||
|
let buffer = CryptographicBuffer::CreateFromByteArray(&bgra)
|
||||||
|
.map_err(|e| TranslateError::parse(format!("构造图像缓冲失败: {e}")))?;
|
||||||
|
SoftwareBitmap::CreateCopyFromBuffer(
|
||||||
|
&buffer,
|
||||||
|
BitmapPixelFormat::Bgra8,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| TranslateError::parse(format!("构造位图失败: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_engine_for(tag: &str) -> Result<OcrEngine, TranslateError> {
|
||||||
|
let language = match Language::CreateLanguage(&windows::core::HSTRING::from(tag)) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(TranslateError::config(format!(
|
||||||
|
"无法解析 OCR 语言「{tag}」: {e}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match OcrEngine::TryCreateFromLanguage(&language) {
|
||||||
|
Ok(engine) => Ok(engine),
|
||||||
|
// Try* 系列「失败」的返回形态(空对象 / Err)在 windows-rs 里不完全一致,
|
||||||
|
// 统一在这里兜住,给用户「去哪儿装语言包」的确切指引
|
||||||
|
Err(_) => Err(missing_language_pack(
|
||||||
|
&format!("系统未安装「{tag}」的 OCR 语言包"),
|
||||||
|
"请在系统「设置 → 时间和语言 → 语言和区域」中添加该语言并勾选「文本识别」,\
|
||||||
|
或把识别语言改回「自动」",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_language_pack(what: &str, how: &str) -> TranslateError {
|
||||||
|
TranslateError::config(format!("{what}。{how}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
//! 取词结果的悬浮窗(非激活)。
|
||||||
|
//!
|
||||||
|
//! 沿用剪贴板预览窗已验证的模式(屏幕外预创建、按光标定位、工作区钳制、内容自适应),
|
||||||
|
//! 但有一处**刻意的差异**:本窗口在创建时就打上 `WS_EX_NOACTIVATE`([`apply_no_activate`]),
|
||||||
|
//! 因此显示时永远不会抢走焦点。这对划词翻译是硬要求——抢焦点会取消用户的选区、打断阅读,
|
||||||
|
//! 弹出一次就把原文弄没了,功能等于不可用。
|
||||||
|
//!
|
||||||
|
//! 代价是**收不到键盘事件**:非激活窗口不持有键盘焦点,Escape 之类的快捷键不可靠。
|
||||||
|
//! 所以本窗口的交互全部走鼠标(复制、换源、关闭都是按钮),并且靠看护线程在
|
||||||
|
//! 「点击窗口外部」时自动收起——不能像剪贴板弹窗那样依赖 `Focused(false)`。
|
||||||
|
//!
|
||||||
|
//! [`apply_no_activate`]: crate::win32_util::apply_no_activate
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::window::{Effect, EffectsBuilder};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
use super::capture::{self, CaptureRequest};
|
||||||
|
use super::settings::TranslateSettings;
|
||||||
|
use super::TranslateManager;
|
||||||
|
use crate::clipboard::reader::{read_clipboard, ClipData};
|
||||||
|
use crate::win32_util::{
|
||||||
|
get_cursor_pos, get_dpi_for_point, get_work_area_at_point,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 窗口 label(与 `capabilities/translate-popup.json`、前端 `WINDOWS.translatePopup` 三处对齐,
|
||||||
|
/// 因此统一取常量而不是写字面量)
|
||||||
|
pub const POPUP_LABEL: &str = crate::constants::windows::TRANSLATE_POPUP;
|
||||||
|
|
||||||
|
/// 弹窗逻辑尺寸。双栏布局(左原文右译文)宽度翻倍;高度只是初值,
|
||||||
|
/// 前端测完内容会调 `translate_popup_resize` 贴合(内容区上限 26rem,见前端模板)。
|
||||||
|
const POPUP_W: f64 = 840.0;
|
||||||
|
const POPUP_BASE_H: f64 = 480.0;
|
||||||
|
|
||||||
|
/// 尺寸上下限(逻辑像素):防止前端异常值把窗口撑到屏幕外或压成一条线。
|
||||||
|
/// MAX_H 高于前端根节点的 max-height(920px)——前端会在内容超过时自行出滚动条,
|
||||||
|
/// 这里只是兜住异常值;实际窗口高度以 syncSize 上报为准,不会被钳到。
|
||||||
|
const MIN_W: f64 = 300.0;
|
||||||
|
const MAX_W: f64 = 900.0;
|
||||||
|
const MIN_H: f64 = 72.0;
|
||||||
|
const MAX_H: f64 = 960.0;
|
||||||
|
|
||||||
|
/// 光标与弹窗之间的间距
|
||||||
|
const GAP: f64 = 12.0;
|
||||||
|
|
||||||
|
/// 看护线程的最大看护时长。超过则自动收起,避免用户离开后弹窗长期滞留在屏幕上。
|
||||||
|
const WATCH_MAX: Duration = Duration::from_secs(120);
|
||||||
|
/// 看护轮询间隔
|
||||||
|
const WATCH_TICK: Duration = Duration::from_millis(40);
|
||||||
|
|
||||||
|
/// 兜底创建路径:窗口是新建的,等前端 `onMounted` 调 ready 再显示
|
||||||
|
static PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// 兜底路径下待投递的负载(前端 ready 时取走)
|
||||||
|
static PENDING_PAYLOAD: Mutex<Option<PopupPayload>> = Mutex::new(None);
|
||||||
|
/// 弹窗当前是否可见(同步单一事实来源,hide 时立刻置 false)
|
||||||
|
static POPUP_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// 钉住状态:钉住后点击外部与超时都不再自动收起(只能手动关闭)。
|
||||||
|
/// hide 时复位——收起即视为本次会话结束,下次打开回到默认自动收起。
|
||||||
|
static PINNED: AtomicBool = AtomicBool::new(false);
|
||||||
|
/// 本次显示的光标锚点与落位偏好。resize 时沿用它重算位置,
|
||||||
|
/// 否则内容变高会从固定左上角往下长、越出工作区。
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Anchor {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
/// 优先落在光标右侧(否则左侧)
|
||||||
|
prefer_right: bool,
|
||||||
|
/// 优先落在光标下方(否则上方)
|
||||||
|
prefer_bottom: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static ANCHOR: Mutex<Option<Anchor>> = Mutex::new(None);
|
||||||
|
/// 看护线程防重入
|
||||||
|
static WATCHING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 面板候选条目(翻译面板打开时收集)。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PanelCandidate {
|
||||||
|
/// 候选文本
|
||||||
|
pub text: String,
|
||||||
|
/// 来源标签:"划词" | "剪贴板"(展示用)
|
||||||
|
pub origin: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 投递给悬浮窗前端的负载。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PopupPayload {
|
||||||
|
/// 待翻译文本(取词失败时为空串)
|
||||||
|
pub text: String,
|
||||||
|
/// 取词来源:"selection"(模拟 Ctrl+C)| "clipboard"(直接读剪贴板)| "screenshot"(截图)| "panel"(翻译面板)
|
||||||
|
pub source: String,
|
||||||
|
/// 取词失败原因(成功时为 None)
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// 来源窗口进程名(展示用)
|
||||||
|
pub process: String,
|
||||||
|
/// 头部补充说明(如「本地识别」「图片已上传至 xx」),比 source 更能回答用户关心的问题
|
||||||
|
pub source_note: Option<String>,
|
||||||
|
/// 来源窗口句柄(P3 回填替换选区用)
|
||||||
|
pub hwnd: i64,
|
||||||
|
/// 剪贴板是否已还原
|
||||||
|
pub restored_clipboard: bool,
|
||||||
|
/// 目标语言(语言码;展示名由前端查语言表得出,避免两处维护同一张表)
|
||||||
|
pub to_lang: String,
|
||||||
|
/// 源语言("auto" 表示交给引擎判断)。
|
||||||
|
///
|
||||||
|
/// 弹窗必须能自己指定源语言:MyMemory 这类源**不支持自动检测**,
|
||||||
|
/// 而划词场景天然不知道原文是什么语言。没有它,这类源在划词里等于不可用。
|
||||||
|
pub source_lang: String,
|
||||||
|
/// 默认引擎实例 id("auto" 表示按优先级)
|
||||||
|
pub engine_id: String,
|
||||||
|
/// 是否默认展开原文
|
||||||
|
pub show_original: bool,
|
||||||
|
pub font_size: u32,
|
||||||
|
/// 正文字号之外的整窗不透明度百分比(100 = 不透明)
|
||||||
|
pub opacity: u32,
|
||||||
|
/// 优先落位:"bottom-right" | "bottom-left" | "top-right" | "top-left"
|
||||||
|
pub position_preference: String,
|
||||||
|
/// 显示后是否立即翻译。截图翻译关闭「自动翻译」时为 false:只展示识别文本,
|
||||||
|
/// 由用户决定是否发翻译请求。
|
||||||
|
pub auto_start: bool,
|
||||||
|
/// 是否写入历史。预览悬浮窗为 false(固定样例文本,入库只是噪音)。
|
||||||
|
pub record: bool,
|
||||||
|
/// 预置结果(视觉直译路径用):识别与翻译在 Rust 侧一步完成,弹窗直接展示,
|
||||||
|
/// 不再发第二次请求。为 None 时弹窗自行用 `text` 走翻译引擎。
|
||||||
|
pub preset_result: Option<super::engines::TranslateResult>,
|
||||||
|
/// 面板候选(仅 source == "panel"):划词结果与剪贴板首条。
|
||||||
|
/// 为空时面板直接聚焦原文输入框,等用户手动输入。
|
||||||
|
#[serde(default)]
|
||||||
|
pub candidates: Vec<PanelCandidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 窗口生命周期 =====
|
||||||
|
|
||||||
|
/// 应用启动时预创建(隐藏)。首次按快捷键时窗口已就绪,直接显示,避免首次创建的时序问题。
|
||||||
|
pub fn ensure_window(app: &AppHandle) {
|
||||||
|
if app.get_webview_window(POPUP_LABEL).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
create_window(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_window(app: &AppHandle) {
|
||||||
|
let win = match WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
POPUP_LABEL,
|
||||||
|
WebviewUrl::App("index.html#translate-popup".into()),
|
||||||
|
)
|
||||||
|
.title("翻译")
|
||||||
|
.inner_size(POPUP_W, POPUP_BASE_H)
|
||||||
|
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏状态下闪一下
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
|
.shadow(true)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.resizable(false)
|
||||||
|
.visible(false)
|
||||||
|
.focused(false) // 不抢占焦点
|
||||||
|
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
crate::logger::log_error("translate", &format!("创建取词悬浮窗失败: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 非激活:显示时不夺取前台。圆角:NOACTIVATE 悬浮窗系统不自动圆角,需显式指定,
|
||||||
|
// 否则与剪贴板弹窗外观不一致。
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::apply_no_activate(hwnd.0 as isize);
|
||||||
|
crate::win32_util::apply_rounded_corners(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::logger::log_info("translate", "取词悬浮窗已预创建(隐藏状态)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示悬浮窗并把负载投递给前端(锚点取当前光标位置)。
|
||||||
|
pub fn show(app: &AppHandle, payload: PopupPayload) {
|
||||||
|
let Some((mx, my)) = get_cursor_pos() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// 先取锚点再移交 payload:show_with_anchor 会拿走所有权,参数求值顺序里
|
||||||
|
// 「先 move 后借用」是编译错误
|
||||||
|
let anchor = Anchor::from(mx, my, &payload.position_preference);
|
||||||
|
show_with_anchor(app, payload, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定锚点显示(截图翻译用):结果要贴在**选区**旁,而截图流程里光标
|
||||||
|
/// 已经离开了原位置,跟着光标走会飘到别处。
|
||||||
|
pub fn show_at(app: &AppHandle, payload: PopupPayload, anchor_x: i32, anchor_y: i32) {
|
||||||
|
let anchor = Anchor::from(anchor_x, anchor_y, &payload.position_preference);
|
||||||
|
show_with_anchor(app, payload, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_with_anchor(app: &AppHandle, payload: PopupPayload, anchor: Anchor) {
|
||||||
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
// 用**当前实际尺寸**参与定位:上一次可能是长译文撑高的窗口,
|
||||||
|
// 恒按 200px 基础高度定位会让底部越界、等前端 resize 才跳回。
|
||||||
|
// outer_size 含四周不可见边框(各约 8px),偏差方向是保守的。
|
||||||
|
let scale = anchor.scale();
|
||||||
|
let (w, h) = win
|
||||||
|
.outer_size()
|
||||||
|
.map(|s| {
|
||||||
|
(
|
||||||
|
(s.width as f64 / scale).max(POPUP_W),
|
||||||
|
s.height as f64 / scale,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((POPUP_W, POPUP_BASE_H));
|
||||||
|
mark_shown(anchor);
|
||||||
|
let (x, y) = position_for(anchor, w, h);
|
||||||
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||||
|
reveal(&win);
|
||||||
|
let _ = app.emit(crate::constants::events::TRANSLATE_POPUP_SHOW, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:窗口被销毁过,重新创建并等前端 ready
|
||||||
|
PENDING_SHOW.store(true, Ordering::SeqCst);
|
||||||
|
if let Ok(mut slot) = PENDING_PAYLOAD.lock() {
|
||||||
|
*slot = Some(payload);
|
||||||
|
}
|
||||||
|
mark_shown(anchor);
|
||||||
|
create_window(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端挂载完成后调用(仅兜底创建路径真正显示)。
|
||||||
|
pub fn ready(app: &AppHandle) {
|
||||||
|
if !PENDING_SHOW.swap(false, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload = PENDING_PAYLOAD.lock().ok().and_then(|mut slot| slot.take());
|
||||||
|
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// 兜底路径窗口建在屏幕外,显示前必须按锚点定位,否则弹窗出现在屏幕外不可见
|
||||||
|
if let Some(anchor) = ANCHOR.lock().ok().and_then(|a| *a) {
|
||||||
|
let (x, y) = position_for(anchor, POPUP_W, POPUP_BASE_H);
|
||||||
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||||
|
}
|
||||||
|
reveal(&win);
|
||||||
|
if let Some(p) = payload {
|
||||||
|
let _ = app.emit(crate::constants::events::TRANSLATE_POPUP_SHOW, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏(保留复用,不销毁)。
|
||||||
|
pub fn hide(app: &AppHandle) {
|
||||||
|
POPUP_VISIBLE.store(false, Ordering::SeqCst);
|
||||||
|
// 钉住状态随之复位:收起即视为本次会话结束
|
||||||
|
PINNED.store(false, Ordering::SeqCst);
|
||||||
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
// 若本次显示期间进入过编辑模式(NOACTIVATE 被移除),在这里恢复,
|
||||||
|
// 保证下一次显示仍然不抢焦点
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::set_no_activate(hwnd.0 as isize, true);
|
||||||
|
}
|
||||||
|
let _ = win.hide();
|
||||||
|
// 以原生 SW_SHOWNOACTIVATE 显示的窗口,Tauri 内部可见性状态可能不同步,
|
||||||
|
// 补一次原生 SW_HIDE,确保任何路径下都被可靠隐藏。
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::hide_window(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = app.emit(crate::constants::events::TRANSLATE_POPUP_HIDE, ());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 弹窗编辑模式开关:移除/恢复 WS_EX_NOACTIVATE,开启时把弹窗推到前台。
|
||||||
|
///
|
||||||
|
/// 弹窗默认是非激活窗口(不抢焦点),代价是收不到键盘事件——原文编辑框因此
|
||||||
|
/// 无法输入。用户点击原文编辑区或译文卡片时前端调用 `enable=true`:移除
|
||||||
|
/// NOACTIVATE 并把弹窗推到前台,编辑框即可获得键盘焦点、译文可 Ctrl+C。
|
||||||
|
/// 弹窗隐藏([`hide`])与下次显示([`reveal`])都会恢复 NOACTIVATE。
|
||||||
|
pub fn set_edit_mode(app: &AppHandle, enable: bool) -> Result<(), String> {
|
||||||
|
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return Err("弹窗不可用".to_string());
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let Ok(hwnd) = win.hwnd() else {
|
||||||
|
return Err("无法获取弹窗句柄".to_string());
|
||||||
|
};
|
||||||
|
crate::win32_util::set_no_activate(hwnd.0 as isize, !enable);
|
||||||
|
if enable {
|
||||||
|
// 用户刚点击了弹窗(本进程持有最新输入),force_foreground 可绕过前台锁定
|
||||||
|
crate::win32_util::force_foreground(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = enable;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按内容尺寸自适应(前端测高后调用),返回前端根节点应使用的 max-height。
|
||||||
|
///
|
||||||
|
/// 返回值 = 光标所在显示器工作区允许的最大内容高度(逻辑像素,0 表示不限制)。
|
||||||
|
/// **刻意来自工作区而不是窗口自身高度**:前端若用 100vh 当上限,窗口缩 → vh 缩 →
|
||||||
|
/// 测得高度缩 → 再缩窗口,形成收缩反馈循环(实测窗口一路缩到 MIN_H)。
|
||||||
|
/// 工作区是稳定值,前端把它设为根节点 max-height 后整个测量回路收敛。
|
||||||
|
///
|
||||||
|
/// 其余行为:**只在越界时钳制,绝不重排回打开时的锚点**。历史实现每次 resize 都按
|
||||||
|
/// `ANCHOR`(打开时的光标位置)重算并 `set_position`——窗口尺寸因内容变化而
|
||||||
|
/// 调整时,会把用户拖动后的位置覆盖掉(实测:钉住面板拖到别处,粘贴内容
|
||||||
|
/// 触发 resize 又跳回原位)。现在以窗口**当前位置**为基准:内容变高向
|
||||||
|
/// 下/向右生长越出工作区时,才把位置钳回来。
|
||||||
|
pub fn resize(app: &AppHandle, width: f64, height: f64) -> f64 {
|
||||||
|
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let w = width.clamp(MIN_W, MAX_W);
|
||||||
|
let h = height.clamp(MIN_H, MAX_H);
|
||||||
|
let Ok(pos) = win.outer_position() else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
let scale = scale_for(pos.x, pos.y);
|
||||||
|
let mut w_px = (w * scale).round() as i32;
|
||||||
|
let mut h_px = (h * scale).round() as i32;
|
||||||
|
let mut max_inner_logical = 0.0f64;
|
||||||
|
let work_area = get_work_area_at_point(pos.x, pos.y);
|
||||||
|
// 小屏适配:窗口尺寸不得超过所在显示器的工作区(留边距)
|
||||||
|
if let Some((left, top, right, bottom)) = work_area {
|
||||||
|
let max_h_px = (bottom - top - 16).max(200);
|
||||||
|
let max_w_px = (right - left - 16).max(200);
|
||||||
|
h_px = h_px.min(max_h_px);
|
||||||
|
w_px = w_px.min(max_w_px);
|
||||||
|
max_inner_logical = max_h_px as f64 / scale;
|
||||||
|
}
|
||||||
|
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize {
|
||||||
|
width: w_px.max(1) as u32,
|
||||||
|
height: h_px.max(1) as u32,
|
||||||
|
}));
|
||||||
|
// 越界才钳制:不越界时保持用户拖动后的位置不动
|
||||||
|
if let Some((left, top, right, bottom)) = work_area {
|
||||||
|
let x = pos.x.clamp(left, (right - w_px).max(left));
|
||||||
|
let y = pos.y.clamp(top, (bottom - h_px).max(top));
|
||||||
|
if x != pos.x || y != pos.y {
|
||||||
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
max_inner_logical
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reveal(win: &tauri::WebviewWindow) {
|
||||||
|
// 每次显示都重置为「不激活」:上一次会话可能以编辑模式结束(NOACTIVATE 被移除)
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::set_no_activate(hwnd.0 as isize, true);
|
||||||
|
}
|
||||||
|
// 不激活显示:保持用户当前窗口的前台状态与选区
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::show_no_activate(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = win.show();
|
||||||
|
spawn_click_outside_watch(win.app_handle().clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Anchor {
|
||||||
|
fn from(x: i32, y: i32, preference: &str) -> Self {
|
||||||
|
match preference {
|
||||||
|
"bottom-left" => Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
prefer_right: false,
|
||||||
|
prefer_bottom: true,
|
||||||
|
},
|
||||||
|
"top-right" => Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
prefer_right: true,
|
||||||
|
prefer_bottom: false,
|
||||||
|
},
|
||||||
|
"top-left" => Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
prefer_right: false,
|
||||||
|
prefer_bottom: false,
|
||||||
|
},
|
||||||
|
// "bottom-right" 与未知值:落回默认(右下),与设置页文案一致
|
||||||
|
_ => Self {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
prefer_right: true,
|
||||||
|
prefer_bottom: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale(&self) -> f64 {
|
||||||
|
scale_for(self.x, self.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_shown(anchor: Anchor) {
|
||||||
|
POPUP_VISIBLE.store(true, Ordering::SeqCst);
|
||||||
|
if let Ok(mut slot) = ANCHOR.lock() {
|
||||||
|
*slot = Some(anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale_for(x: i32, y: i32) -> f64 {
|
||||||
|
get_dpi_for_point(x, y).unwrap_or(96) as f64 / 96.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按锚点与落位偏好算出弹窗左上角(物理像素)。
|
||||||
|
///
|
||||||
|
/// 两级处理,意义不同:先按偏好落位,放不下时**翻到另一侧**(处理常见情况,
|
||||||
|
/// 例如光标贴着屏幕右缘);最后再钳制到工作区(兜住极端情况,例如窗口比屏幕还宽)。
|
||||||
|
fn position_for(anchor: Anchor, w: f64, h: f64) -> (i32, i32) {
|
||||||
|
let (left, top, right, bottom) =
|
||||||
|
get_work_area_at_point(anchor.x, anchor.y).unwrap_or((0, 0, 1920, 1040));
|
||||||
|
let scale = anchor.scale();
|
||||||
|
let w_px = w * scale;
|
||||||
|
let h_px = h * scale;
|
||||||
|
|
||||||
|
let mut x = if anchor.prefer_right {
|
||||||
|
anchor.x as f64 + GAP
|
||||||
|
} else {
|
||||||
|
anchor.x as f64 - GAP - w_px
|
||||||
|
};
|
||||||
|
if anchor.prefer_right && x + w_px > right as f64 {
|
||||||
|
x = anchor.x as f64 - GAP - w_px;
|
||||||
|
} else if !anchor.prefer_right && x < left as f64 {
|
||||||
|
x = anchor.x as f64 + GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut y = if anchor.prefer_bottom {
|
||||||
|
anchor.y as f64 + GAP
|
||||||
|
} else {
|
||||||
|
anchor.y as f64 - GAP - h_px
|
||||||
|
};
|
||||||
|
if anchor.prefer_bottom && y + h_px > bottom as f64 {
|
||||||
|
y = anchor.y as f64 - GAP - h_px;
|
||||||
|
} else if !anchor.prefer_bottom && y < top as f64 {
|
||||||
|
y = anchor.y as f64 + GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_x = left as f64;
|
||||||
|
let max_x = (right as f64 - w_px).max(min_x);
|
||||||
|
let min_y = top as f64;
|
||||||
|
let max_y = (bottom as f64 - h_px).max(min_y);
|
||||||
|
(
|
||||||
|
x.clamp(min_x, max_x).round() as i32,
|
||||||
|
y.clamp(min_y, max_y).round() as i32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置钉住状态(前端钉住按钮调用)。
|
||||||
|
pub fn set_pinned(pinned: bool) {
|
||||||
|
PINNED.store(pinned, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击弹窗外部即收起。
|
||||||
|
///
|
||||||
|
/// 为什么不用 `Focused(false)`:本窗口是 NOACTIVATE 的,永远不会获得焦点,
|
||||||
|
/// 也就永远收不到失焦事件。只能主动轮询「左键按下沿是否发生在窗口外」。
|
||||||
|
/// **钉住时整条自动收起路径失效**(点击外部与超时都不收),只能手动关闭。
|
||||||
|
fn spawn_click_outside_watch(app: AppHandle) {
|
||||||
|
if WATCHING.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut was_down = false;
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(WATCH_TICK);
|
||||||
|
if !POPUP_VISIBLE.load(Ordering::SeqCst)
|
||||||
|
|| app.get_webview_window(POPUP_LABEL).is_none()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pinned = PINNED.load(Ordering::SeqCst);
|
||||||
|
if start.elapsed() > WATCH_MAX && !pinned {
|
||||||
|
hide(&app);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let down = crate::win32_util::is_left_button_down();
|
||||||
|
// 按下沿判定:上一轮未按下、本轮按下,且落点在窗口外
|
||||||
|
if down && !was_down && !pinned && !cursor_in_popup(&app) {
|
||||||
|
hide(&app);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
was_down = down;
|
||||||
|
}
|
||||||
|
WATCHING.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_in_popup(app: &AppHandle) -> bool {
|
||||||
|
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let (Ok(pos), Ok(size)) = (win.outer_position(), win.outer_size()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((cx, cy)) = get_cursor_pos() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
cx >= pos.x && cx <= pos.x + size.width as i32 && cy >= pos.y && cy <= pos.y + size.height as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 快捷键与翻译面板入口 =====
|
||||||
|
|
||||||
|
/// 按当前设置注册/注销「翻译面板」全局快捷键(默认 Ctrl+2)。
|
||||||
|
///
|
||||||
|
/// 面板打开时会尝试读取当前划词(若「启用划词翻译」)与剪贴板首条文本作为候选,
|
||||||
|
/// 覆盖了旧版「翻译取词 Alt+T」「翻译剪贴板 Alt+Shift+T」两个入口的全部场景;
|
||||||
|
/// 那两个快捷键因此移除,这里顺带注销其历史注册(升级后首次应用设置时清理)。
|
||||||
|
///
|
||||||
|
/// 设置项为空串即注销(`register_shortcut` 的既定语义)。
|
||||||
|
pub fn apply_shortcuts(app: &AppHandle) -> Result<(), String> {
|
||||||
|
let Some(manager) = app.try_state::<TranslateManager>() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let panel_key = manager.load_settings().selection.panel_shortcut.clone();
|
||||||
|
|
||||||
|
// 旧版入口已不存在,显式清理历史注册,释放被 Alt+T / Alt+Shift+T 占用的组合键
|
||||||
|
crate::shortcut::unregister_shortcut(app, "翻译取词");
|
||||||
|
crate::shortcut::unregister_shortcut(app, "翻译剪贴板");
|
||||||
|
|
||||||
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
let r = crate::shortcut::register_shortcut(app, "翻译面板", &panel_key, |handle| {
|
||||||
|
let handle = handle.clone();
|
||||||
|
// 收集候选含最长约 1s 的阻塞等待,绝不能跑在快捷键回调线程上
|
||||||
|
std::thread::spawn(move || on_panel_shortcut(&handle));
|
||||||
|
});
|
||||||
|
if let Err(e) = r {
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷键生效就顺手预创建窗口,避免首次触发时现建 WebView
|
||||||
|
if !panel_key.trim().is_empty() {
|
||||||
|
ensure_window(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(errors.join(";"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 面板候选:划词(若启用且非 clipboard 模式)+ 剪贴板首条文本(去重)。
|
||||||
|
///
|
||||||
|
/// 顺序执行而非并行:兼容路径取词会临时占用剪贴板,并行读会拿到中间状态。
|
||||||
|
/// 取词失败静默跳过——面板里还有剪贴板候选与手动输入,失败提示反而碍事
|
||||||
|
/// (与旧取词路径「失败也弹窗」不同,这里失败不阻断打开面板)。
|
||||||
|
fn collect_candidates(app: &AppHandle) -> Vec<PanelCandidate> {
|
||||||
|
let Some(manager) = app.try_state::<TranslateManager>() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let settings = manager.load_settings();
|
||||||
|
let sel = settings.selection.clone();
|
||||||
|
let mut out: Vec<PanelCandidate> = Vec::new();
|
||||||
|
|
||||||
|
if sel.enabled && sel.mode.trim() != "clipboard" {
|
||||||
|
let req = CaptureRequest {
|
||||||
|
max_chars: sel.max_chars as usize,
|
||||||
|
restore_clipboard: sel.restore_clipboard,
|
||||||
|
blacklist: sel.blacklist.clone(),
|
||||||
|
mode: sel.mode.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(outcome) = capture::capture_selection(app, &req) {
|
||||||
|
if !outcome.text.trim().is_empty() {
|
||||||
|
out.push(PanelCandidate {
|
||||||
|
text: outcome.text,
|
||||||
|
origin: "划词".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只读不写:不模拟按键,终端里的 Ctrl+C 不受影响
|
||||||
|
if let Some(ClipData::Text(t)) = read_clipboard() {
|
||||||
|
let t = t.trim().to_string();
|
||||||
|
if !t.is_empty() && !out.iter().any(|c| c.text == t) {
|
||||||
|
out.push(PanelCandidate {
|
||||||
|
text: t,
|
||||||
|
origin: "剪贴板".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_panel_shortcut(app: &AppHandle) {
|
||||||
|
// 已显示:保持原状态(内容不动),只把它带到前台恢复键盘输入
|
||||||
|
if POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
focus_panel(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(manager) = app.try_state::<TranslateManager>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let settings = manager.load_settings();
|
||||||
|
// 候选必须在面板抢焦点**之前**收集:取词(UIA / 模拟按键)都作用在
|
||||||
|
// 当时的前台目标应用上,面板一旦激活,取到的就只剩面板自己了。
|
||||||
|
// 代价是按下快捷键到面板出现要等一次取词(UIA 命中时毫秒级,最差约 1s)。
|
||||||
|
let candidates = collect_candidates(app);
|
||||||
|
// 收集期间用户可能已通过其他方式打开了面板(如再次连按):不再覆盖
|
||||||
|
if POPUP_VISIBLE.load(Ordering::SeqCst) {
|
||||||
|
focus_panel(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut payload =
|
||||||
|
build_payload(&settings, String::new(), "panel", None, String::new(), 0, false);
|
||||||
|
// 面板不自动翻译:原文空着,等用户选候选或手动输入
|
||||||
|
payload.auto_start = false;
|
||||||
|
payload.candidates = candidates;
|
||||||
|
show(app, payload);
|
||||||
|
// 面板需要键盘输入(候选导航、原文编辑),显示后立即取得焦点
|
||||||
|
focus_panel(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把弹窗带到前台并恢复键盘输入(移除 NOACTIVATE + 强制前台)。
|
||||||
|
fn focus_panel(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(hwnd) = win.hwnd() {
|
||||||
|
crate::win32_util::set_no_activate(hwnd.0 as isize, false);
|
||||||
|
crate::win32_util::force_foreground(hwnd.0 as isize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_payload(
|
||||||
|
settings: &TranslateSettings,
|
||||||
|
text: String,
|
||||||
|
source: &str,
|
||||||
|
error: Option<String>,
|
||||||
|
process: String,
|
||||||
|
hwnd: i64,
|
||||||
|
restored_clipboard: bool,
|
||||||
|
) -> PopupPayload {
|
||||||
|
build_payload_with(
|
||||||
|
settings,
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
error,
|
||||||
|
process,
|
||||||
|
hwnd,
|
||||||
|
restored_clipboard,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组装弹窗负载。`source_note` / `preset_result` 只有截图链路会用到。
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_payload_with(
|
||||||
|
settings: &TranslateSettings,
|
||||||
|
text: String,
|
||||||
|
source: &str,
|
||||||
|
error: Option<String>,
|
||||||
|
process: String,
|
||||||
|
hwnd: i64,
|
||||||
|
restored_clipboard: bool,
|
||||||
|
source_note: Option<String>,
|
||||||
|
preset_result: Option<super::engines::TranslateResult>,
|
||||||
|
) -> PopupPayload {
|
||||||
|
PopupPayload {
|
||||||
|
text,
|
||||||
|
source: source.to_string(),
|
||||||
|
error,
|
||||||
|
process,
|
||||||
|
source_note,
|
||||||
|
hwnd,
|
||||||
|
restored_clipboard,
|
||||||
|
to_lang: settings
|
||||||
|
.popup
|
||||||
|
.effective_target_lang(&settings.default_target)
|
||||||
|
.to_string(),
|
||||||
|
source_lang: settings.popup.effective_source_lang().to_string(),
|
||||||
|
engine_id: settings.default_engine_id.clone(),
|
||||||
|
show_original: settings.popup.show_original,
|
||||||
|
font_size: settings.popup.font_size,
|
||||||
|
opacity: settings.popup.opacity,
|
||||||
|
position_preference: settings.popup.position_preference.clone(),
|
||||||
|
auto_start: true,
|
||||||
|
// 预览(source == "preview")用固定样例文本走链路,入库只是噪音
|
||||||
|
record: source != "preview",
|
||||||
|
preset_result,
|
||||||
|
candidates: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览用负载:用一段固定文本走完整链路(窗口定位、非激活显示、翻译、复制)。
|
||||||
|
///
|
||||||
|
/// 存在的理由:**从应用内无法真正测试取词**——测试时前台窗口是本应用自己,
|
||||||
|
/// Ctrl+C 只会落到一个没有选区的窗口上。所以就"验证悬浮窗这条链路"而言,
|
||||||
|
/// 预览比伪造一次取词更有意义,也让用户不必离开设置页去试。
|
||||||
|
pub fn preview_payload(settings: &TranslateSettings) -> PopupPayload {
|
||||||
|
build_payload(
|
||||||
|
settings,
|
||||||
|
"The quick brown fox jumps over the lazy dog.".to_string(),
|
||||||
|
"preview",
|
||||||
|
None,
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图翻译的结果弹窗入口(锚点为选区右下角,结果贴在选区旁)。
|
||||||
|
///
|
||||||
|
/// `preset_result` 为 Some 时表示识别与翻译已在 Rust 侧一步完成(视觉直译),
|
||||||
|
/// 弹窗只负责展示;为 None 时弹窗用 `text`(OCR 文本)自行走翻译引擎。
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn show_screenshot_result(
|
||||||
|
app: &AppHandle,
|
||||||
|
settings: &TranslateSettings,
|
||||||
|
text: String,
|
||||||
|
source_note: Option<String>,
|
||||||
|
preset_result: Option<super::engines::TranslateResult>,
|
||||||
|
auto_start: bool,
|
||||||
|
anchor_x: i32,
|
||||||
|
anchor_y: i32,
|
||||||
|
) {
|
||||||
|
let mut payload = build_payload(
|
||||||
|
settings,
|
||||||
|
text,
|
||||||
|
"screenshot",
|
||||||
|
None,
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
payload.source_note = source_note;
|
||||||
|
payload.preset_result = preset_result;
|
||||||
|
payload.auto_start = auto_start;
|
||||||
|
show_at(app, payload, anchor_x, anchor_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图链路的失败弹窗:识别失败也弹窗,用户视线就在选区那里。
|
||||||
|
pub fn show_screenshot_error(
|
||||||
|
app: &AppHandle,
|
||||||
|
settings: &TranslateSettings,
|
||||||
|
message: String,
|
||||||
|
anchor_x: i32,
|
||||||
|
anchor_y: i32,
|
||||||
|
) {
|
||||||
|
let mut payload = build_payload(
|
||||||
|
settings,
|
||||||
|
String::new(),
|
||||||
|
"screenshot",
|
||||||
|
Some(message),
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
payload.auto_start = false;
|
||||||
|
show_at(app, payload, anchor_x, anchor_y);
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
//! 翻译模块设置的数据模型与默认值。
|
||||||
|
//!
|
||||||
|
//! 持久化位置:`{app_data_dir}/translate/settings.json`(与音乐模块同一范式)。
|
||||||
|
//! 容器级 `#[serde(default)]`:新增字段对旧配置文件是**向后兼容**的——缺字段取
|
||||||
|
//! 默认值而不是让整份设置反序列化失败,避免用户因为一次升级丢掉全部配置。
|
||||||
|
//!
|
||||||
|
//! 安全姿态:**API Key 不在这里**。本结构只允许出现 `hasApiKey` 这类派生展示字段
|
||||||
|
//! (且由命令层回填,不落盘),明文一律进系统凭据管理器,见 [`super::engine_secret_key`]。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
|
||||||
|
use super::engines::TranslateMode;
|
||||||
|
|
||||||
|
/// 提示词模板。`{target}` 为占位符,翻译时替换为目标语言的自然语言全称
|
||||||
|
/// (用全称而非语言码,模型遵循度明显更高)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct PromptTemplates {
|
||||||
|
/// 纯翻译(默认模式)
|
||||||
|
pub translate: String,
|
||||||
|
/// 润色(P3)
|
||||||
|
pub polish: String,
|
||||||
|
/// 解释(P3)
|
||||||
|
pub explain: String,
|
||||||
|
/// 总结(P3)
|
||||||
|
pub summarize: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PromptTemplates {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
translate: "你是专业翻译引擎。将用户内容翻译为{target}。\n\
|
||||||
|
要求:只输出译文,不解释、不加引号、不保留原文;保持术语、代码片段、\
|
||||||
|
专有名词、数字与原有格式(Markdown / 换行 / 列表)不变;\
|
||||||
|
若原文已经是{target},则原样返回。"
|
||||||
|
.to_string(),
|
||||||
|
polish:
|
||||||
|
"你是专业文字编辑。将用户内容改写为更地道、通顺的{target},保持原意与信息量不变。\
|
||||||
|
只输出改写结果,不解释。"
|
||||||
|
.to_string(),
|
||||||
|
explain:
|
||||||
|
"你是专业讲解者。用{target}解释用户给出的内容:先说要点,再说明背景与可能的歧义。\
|
||||||
|
保持简洁,不要复述原文。"
|
||||||
|
.to_string(),
|
||||||
|
summarize:
|
||||||
|
"你是专业摘要助手。用{target}总结用户给出的内容,保留关键事实、数字与结论,\
|
||||||
|
去掉冗余表述。只输出摘要。"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromptTemplates {
|
||||||
|
/// 取指定模式的模板(未填写时退回默认模板)。
|
||||||
|
pub fn for_mode(&self, mode: TranslateMode) -> &str {
|
||||||
|
let candidate = match mode {
|
||||||
|
TranslateMode::Translate => &self.translate,
|
||||||
|
TranslateMode::Polish => &self.polish,
|
||||||
|
TranslateMode::Explain => &self.explain,
|
||||||
|
TranslateMode::Summarize => &self.summarize,
|
||||||
|
};
|
||||||
|
if candidate.trim().is_empty() {
|
||||||
|
self.translate.as_str()
|
||||||
|
} else {
|
||||||
|
candidate.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个翻译引擎实例的配置。
|
||||||
|
///
|
||||||
|
/// 「实例」而非「类型」:同一类型可以配置多份(例如官方 API 与本地 Ollama 并存),
|
||||||
|
/// 每份有自己的 id / 优先级 / 模型与参数。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct TranslateEngineConfig {
|
||||||
|
/// 实例唯一标识(同时是凭据键的一部分,创建后不建议修改)
|
||||||
|
pub id: String,
|
||||||
|
/// 展示名称
|
||||||
|
pub name: String,
|
||||||
|
/// 引擎类型:"ai"(OpenAI 兼容)| "free"(免密钥网络源)| "cloud"(需签名的云厂商,P3)
|
||||||
|
pub kind: String,
|
||||||
|
/// 预设标识:"deepseek" | "openai" | "ollama" | "custom" | "libretranslate" | "mymemory"
|
||||||
|
pub preset: String,
|
||||||
|
/// 是否参与「自动」模式的候选
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 优先级(数值越小越先尝试;自动模式下失败按序降级到下一个)
|
||||||
|
pub priority: i32,
|
||||||
|
/// API 根地址,如 https://api.deepseek.com(末尾可有可无 /,不可含 /chat/completions)
|
||||||
|
pub base_url: String,
|
||||||
|
/// 模型标识,如 deepseek-flash
|
||||||
|
pub model: String,
|
||||||
|
/// 采样温度(翻译建议 0.2~0.3)
|
||||||
|
pub temperature: f64,
|
||||||
|
/// 单次请求最大输出 token
|
||||||
|
pub max_tokens: u32,
|
||||||
|
/// 请求超时(毫秒)
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
/// 自定义 system prompt;**非空时覆盖模式模板**(留空表示用全局模板)
|
||||||
|
pub system_prompt: String,
|
||||||
|
/// 额外请求体字段的 JSON 文本(用于 thinking / enable_thinking 这类非标准参数);
|
||||||
|
/// 用文本而非结构化字段:一是前端直接给文本域,二是避免把任意 JSON 塞进类型绑定。
|
||||||
|
pub extra_body: Option<String>,
|
||||||
|
/// 是否支持图像输入(截图翻译的「视觉直译」模式只发给勾选了此项的引擎)。
|
||||||
|
/// 默认 false:多模态模型与文本模型的计费和端点约束不同,宁可让用户显式勾选。
|
||||||
|
#[serde(default)]
|
||||||
|
pub supports_vision: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TranslateEngineConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
kind: "ai".to_string(),
|
||||||
|
preset: "custom".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
base_url: String::new(),
|
||||||
|
model: String::new(),
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4096,
|
||||||
|
timeout_ms: 30_000,
|
||||||
|
system_prompt: String::new(),
|
||||||
|
extra_body: None,
|
||||||
|
supports_vision: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranslateEngineConfig {
|
||||||
|
/// 内置 DeepSeek 预设:默认引擎。模型标识取现行名 `deepseek-flash`
|
||||||
|
/// (`deepseek-chat` / `deepseek-reasoner` 已于 2026-07-24 停用;`deepseek-v4-flash`
|
||||||
|
/// 等旧名虽仍被受理,但请求已被路由到 V4.1-Flash,不适合再写进默认配置)。
|
||||||
|
pub fn deepseek_default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: "deepseek".to_string(),
|
||||||
|
name: "DeepSeek Flash".to_string(),
|
||||||
|
kind: "ai".to_string(),
|
||||||
|
preset: "deepseek".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
priority: 10,
|
||||||
|
base_url: "https://api.deepseek.com".to_string(),
|
||||||
|
model: "deepseek-flash".to_string(),
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4096,
|
||||||
|
timeout_ms: 30_000,
|
||||||
|
// 翻译场景用非思考模式:不写 thinking / reasoning_effort,换低延迟低费用
|
||||||
|
system_prompt: String::new(),
|
||||||
|
extra_body: None,
|
||||||
|
supports_vision: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 划词翻译设置(P1 生效;结构在 P0 即定型,避免 P2 改数据结构)。
|
||||||
|
///
|
||||||
|
/// P2 重构说明:原「取词快捷键 Alt+T」「翻译剪贴板 Alt+Shift+T」已移除,
|
||||||
|
/// 统一为「翻译面板」快捷键(`panel_shortcut`)——面板打开时自动尝试读取
|
||||||
|
/// 当前划词与剪贴板首条作为候选,覆盖了原来两个入口的全部场景。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct SelectionSettings {
|
||||||
|
/// 总开关:打开翻译面板时是否尝试读取当前划词作为候选
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 打开翻译面板的全局快捷键(默认 Ctrl+2)
|
||||||
|
#[serde(default = "default_panel_shortcut")]
|
||||||
|
pub panel_shortcut: String,
|
||||||
|
/// 取词方式:
|
||||||
|
/// - "smart"(默认):先用 UIA 直读选区,读不到再退回模拟 Ctrl+C。不碰剪贴板,
|
||||||
|
/// 因此不受「目标窗口提权」「Alt 仍被按住导致 Ctrl+C 变成 Alt+Ctrl+C」这两类问题影响。
|
||||||
|
/// - "compat":只用模拟 Ctrl+C(覆盖最广,但会短暂占用剪贴板)。
|
||||||
|
/// - "clipboard":不取词,面板只提供剪贴板首条作为候选。
|
||||||
|
pub mode: String,
|
||||||
|
/// 取词后是否还原剪贴板(关掉则保留选中文本)
|
||||||
|
pub restore_clipboard: bool,
|
||||||
|
/// 单次取词的字符数上限。超出直接拒绝并提示:划词误选整篇文档时,
|
||||||
|
/// 发一个几万字的请求既慢又费钱,不如让用户明确知道发生了什么。
|
||||||
|
pub max_chars: u32,
|
||||||
|
/// 取词跳过的进程名黑名单(终端类 Ctrl+C 是中断信号,必须排除)
|
||||||
|
pub blacklist: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_panel_shortcut() -> String {
|
||||||
|
"Ctrl+2".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SelectionSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// P0 未实现取词,默认关闭以免给出「已开启但无反应」的错误预期
|
||||||
|
enabled: false,
|
||||||
|
panel_shortcut: default_panel_shortcut(),
|
||||||
|
// 默认「智能」:UIA 直读优先,读不到再退回模拟 Ctrl+C
|
||||||
|
mode: "smart".to_string(),
|
||||||
|
restore_clipboard: true,
|
||||||
|
max_chars: 2000,
|
||||||
|
blacklist: vec![
|
||||||
|
"WindowsTerminal.exe".to_string(),
|
||||||
|
"conhost.exe".to_string(),
|
||||||
|
"mintty.exe".to_string(),
|
||||||
|
"OpenConsole.exe".to_string(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 划词结果悬浮窗外观(P1 生效)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct PopupSettings {
|
||||||
|
/// 正文字号(逻辑像素)
|
||||||
|
pub font_size: u32,
|
||||||
|
/// 不透明度百分比(100 = 不透明)
|
||||||
|
pub opacity: u32,
|
||||||
|
/// 优先落位:"bottom-right" | "bottom-left" | "top-right" | "top-left"
|
||||||
|
pub position_preference: String,
|
||||||
|
/// 是否默认展开原文
|
||||||
|
pub show_original: bool,
|
||||||
|
/// 划词弹窗的源语言("auto" 表示交给引擎判断)。
|
||||||
|
///
|
||||||
|
/// 存在的理由:MyMemory 这类源**不支持自动检测**,而划词场景天然不知道源语言。
|
||||||
|
/// 与其让弹窗永远传 auto、把这类源判成「不可用」,不如让用户在这里定一次并记住。
|
||||||
|
#[serde(default = "default_source_lang")]
|
||||||
|
pub source_lang: String,
|
||||||
|
/// 划词弹窗的目标语言。空串表示跟随全局「默认目标语言」。
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_lang: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_source_lang() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PopupSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
font_size: 13,
|
||||||
|
opacity: 100,
|
||||||
|
position_preference: "bottom-right".to_string(),
|
||||||
|
show_original: true,
|
||||||
|
source_lang: default_source_lang(),
|
||||||
|
target_lang: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopupSettings {
|
||||||
|
/// 生效的源语言:空串(旧配置)按 auto 处理。
|
||||||
|
pub fn effective_source_lang(&self) -> &str {
|
||||||
|
let v = self.source_lang.trim();
|
||||||
|
if v.is_empty() {
|
||||||
|
"auto"
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生效的目标语言:未单独设置时跟随全局默认。
|
||||||
|
pub fn effective_target_lang<'a>(&'a self, fallback: &'a str) -> &'a str {
|
||||||
|
let v = self.target_lang.trim();
|
||||||
|
if v.is_empty() {
|
||||||
|
fallback
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图翻译设置。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ScreenshotSettings {
|
||||||
|
/// 识别方式:"windows"(Windows.Media.Ocr,本地离线,默认)| "vision"(视觉模型直译)
|
||||||
|
pub ocr_mode: String,
|
||||||
|
/// OCR 语言:"auto" 或 BCP-47 标签(如 en-US / zh-Hans-CN)
|
||||||
|
pub ocr_lang: String,
|
||||||
|
/// 框选完成后是否自动翻译(关闭则只识别,译文由用户在悬浮窗手动触发)
|
||||||
|
pub auto_translate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScreenshotSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ocr_mode: "windows".to_string(),
|
||||||
|
ocr_lang: "auto".to_string(),
|
||||||
|
auto_translate: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 翻译历史设置(P2 生效)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct HistorySettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 保留条数上限(超出按时间淘汰)
|
||||||
|
pub max_items: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HistorySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
max_items: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 翻译模块设置根结构。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct TranslateSettings {
|
||||||
|
/// 结构版本号(用于后续迁移判断)
|
||||||
|
pub version: u32,
|
||||||
|
/// 默认目标语言(内部代码,如 zh-Hans)
|
||||||
|
pub default_target: String,
|
||||||
|
/// 默认引擎实例 id("auto" 表示按优先级自动降级)
|
||||||
|
pub default_engine_id: String,
|
||||||
|
/// 自动模式下的失败降级开关
|
||||||
|
pub auto_fallback: bool,
|
||||||
|
/// 翻译请求是否走代理模块(mihomo mixed 端口)。
|
||||||
|
/// **默认关闭**:自建 LibreTranslate 在境内、MyMemory 与 DeepSeek 均可直连,
|
||||||
|
/// 不需要代理;仅当翻译服务部署在境外或直连被拦截时由用户显式打开。
|
||||||
|
pub use_proxy: bool,
|
||||||
|
/// 引擎实例列表
|
||||||
|
pub engines: Vec<TranslateEngineConfig>,
|
||||||
|
pub selection: SelectionSettings,
|
||||||
|
pub popup: PopupSettings,
|
||||||
|
pub screenshot: ScreenshotSettings,
|
||||||
|
pub history: HistorySettings,
|
||||||
|
pub prompt_templates: PromptTemplates,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TranslateSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
default_target: "zh-Hans".to_string(),
|
||||||
|
default_engine_id: "deepseek".to_string(),
|
||||||
|
auto_fallback: true,
|
||||||
|
use_proxy: false,
|
||||||
|
engines: vec![TranslateEngineConfig::deepseek_default()],
|
||||||
|
selection: SelectionSettings::default(),
|
||||||
|
popup: PopupSettings::default(),
|
||||||
|
screenshot: ScreenshotSettings::default(),
|
||||||
|
history: HistorySettings::default(),
|
||||||
|
prompt_templates: PromptTemplates::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranslateSettings {
|
||||||
|
/// 按 id 找引擎实例。
|
||||||
|
pub fn engine(&self, id: &str) -> Option<&TranslateEngineConfig> {
|
||||||
|
self.engines.iter().find(|e| e.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 参与「自动」模式的引擎,按优先级升序。
|
||||||
|
pub fn auto_candidates(&self) -> Vec<&TranslateEngineConfig> {
|
||||||
|
let mut list: Vec<&TranslateEngineConfig> = self.engines.iter().filter(|e| e.enabled).collect();
|
||||||
|
list.sort_by_key(|e| e.priority);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自愈:列表为空时补回 DeepSeek 默认实例;默认引擎 id 失效时回落到第一个可用实例。
|
||||||
|
/// 返回是否发生了变更(由调用方决定落盘)。
|
||||||
|
pub fn heal(&mut self) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
if self.engines.is_empty() {
|
||||||
|
self.engines.push(TranslateEngineConfig::deepseek_default());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if self.default_target.trim().is_empty() {
|
||||||
|
self.default_target = "zh-Hans".to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if self.default_engine_id.trim().is_empty() {
|
||||||
|
self.default_engine_id = "auto".to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if self.default_engine_id != "auto" && self.engine(&self.default_engine_id).is_none() {
|
||||||
|
// 指向的实例已被删除:不要静默改写成某个实例,改为「自动」更符合用户预期
|
||||||
|
self.default_engine_id = "auto".to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// v1 → v2:取词方式默认值从 "compat" 改为 "smart"(UIA 直读已实现)。
|
||||||
|
// v1 里这个字段没有任何 UI 入口,用户不可能主动选过它,因此直接迁移是安全的;
|
||||||
|
// 迁移后用户再选 "compat" 会被原样保留(版本号已推进,不会再被改写)。
|
||||||
|
if self.version < 2 {
|
||||||
|
if self.selection.mode.trim() == "compat" {
|
||||||
|
self.selection.mode = "smart".to_string();
|
||||||
|
}
|
||||||
|
self.version = 2;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-104
@@ -21,10 +21,10 @@ use tauri::{
|
|||||||
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
|
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
|
|
||||||
/// 保存最近一次右键时计算出的定位参数(物理坐标),供 `tray_menu_ready` 使用
|
/// 保存最近一次右键时计算出的定位参数(物理坐标),供 `tray_menu_ready` 使用
|
||||||
/// (x, tray_top, wa_top, wa_bottom, scale)
|
/// (x, cursor_y, screen_top, screen_bottom, scale)
|
||||||
static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = Mutex::new(None);
|
static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = Mutex::new(None);
|
||||||
|
|
||||||
use crate::win32_util::{get_work_area, get_work_area_at_point, get_dpi_for_point};
|
use crate::win32_util::{get_work_area, get_monitor_bounds_at_point, get_dpi_for_point};
|
||||||
use crate::mihomo_manager::{MihomoManager, is_pseudo_node};
|
use crate::mihomo_manager::{MihomoManager, is_pseudo_node};
|
||||||
use crate::monitor_kernel::MonitorKernel;
|
use crate::monitor_kernel::MonitorKernel;
|
||||||
use crate::process_manager::{ProcessManager, ProcessStatus};
|
use crate::process_manager::{ProcessManager, ProcessStatus};
|
||||||
@@ -46,6 +46,7 @@ pub struct ProxyNodeInfo {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TrayMenuState {
|
pub struct TrayMenuState {
|
||||||
pub proxy_running: bool,
|
pub proxy_running: bool,
|
||||||
|
pub system_proxy: bool,
|
||||||
pub monitor_running: bool,
|
pub monitor_running: bool,
|
||||||
pub proxy_group: Option<String>,
|
pub proxy_group: Option<String>,
|
||||||
pub proxy_nodes: Vec<ProxyNodeInfo>,
|
pub proxy_nodes: Vec<ProxyNodeInfo>,
|
||||||
@@ -147,9 +148,29 @@ async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Opti
|
|||||||
|
|
||||||
// ===== 获取菜单状态 =====
|
// ===== 获取菜单状态 =====
|
||||||
|
|
||||||
pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
|
/// 基础状态(不含代理节点)。托盘菜单显示不应受 mihomo API 慢/卡死影响,
|
||||||
|
/// 因此先秒发基础状态让菜单立即出现,代理节点随后异步补充。
|
||||||
|
fn get_base_tray_state(app: &AppHandle) -> TrayMenuState {
|
||||||
let proxy_running = is_proxy_running(app);
|
let proxy_running = is_proxy_running(app);
|
||||||
let monitor_running = is_monitor_running(app);
|
let monitor_running = is_monitor_running(app);
|
||||||
|
// 读取 Windows 注册表中的真实系统代理状态(不依赖 settings.json 缓存)
|
||||||
|
let system_proxy = crate::mihomo_manager::get_system_proxy_windows();
|
||||||
|
TrayMenuState {
|
||||||
|
proxy_running,
|
||||||
|
system_proxy,
|
||||||
|
monitor_running,
|
||||||
|
proxy_group: None,
|
||||||
|
proxy_nodes: vec![],
|
||||||
|
proxy_current: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完整状态(含代理节点)。可能因调用 mihomo /proxies 而耗时(最长 10s)。
|
||||||
|
async fn get_full_tray_state(app: &AppHandle) -> TrayMenuState {
|
||||||
|
let proxy_running = is_proxy_running(app);
|
||||||
|
let monitor_running = is_monitor_running(app);
|
||||||
|
// 读取 Windows 注册表中的真实系统代理状态(不依赖 settings.json 缓存)
|
||||||
|
let system_proxy = crate::mihomo_manager::get_system_proxy_windows();
|
||||||
|
|
||||||
let (proxy_group, proxy_nodes, proxy_current) = if proxy_running {
|
let (proxy_group, proxy_nodes, proxy_current) = if proxy_running {
|
||||||
match fetch_proxy_nodes(app).await {
|
match fetch_proxy_nodes(app).await {
|
||||||
@@ -172,6 +193,7 @@ pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
|
|||||||
|
|
||||||
TrayMenuState {
|
TrayMenuState {
|
||||||
proxy_running,
|
proxy_running,
|
||||||
|
system_proxy,
|
||||||
monitor_running,
|
monitor_running,
|
||||||
proxy_group,
|
proxy_group,
|
||||||
proxy_nodes,
|
proxy_nodes,
|
||||||
@@ -179,6 +201,10 @@ pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
|
||||||
|
get_full_tray_state(app).await
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 显示/隐藏托盘菜单窗口 =====
|
// ===== 显示/隐藏托盘菜单窗口 =====
|
||||||
|
|
||||||
/// 托盘菜单窗口尺寸(逻辑像素)
|
/// 托盘菜单窗口尺寸(逻辑像素)
|
||||||
@@ -245,29 +271,30 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。
|
/// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。
|
||||||
/// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部精确对齐托盘图标。
|
/// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部对齐鼠标点击位置。
|
||||||
///
|
///
|
||||||
/// `cursor_pos`: 事件报告的鼠标物理坐标;`tray_rect`: 托盘图标区域(物理像素)。
|
/// `cursor_pos`: 事件报告的鼠标物理坐标。
|
||||||
pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64, f64, f64, f64)) {
|
pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64)) {
|
||||||
let (mx, my) = (cursor_pos.0, cursor_pos.1);
|
let (mx, my) = (cursor_pos.0, cursor_pos.1);
|
||||||
let tray_top = tray_rect.1;
|
|
||||||
|
|
||||||
// 获取光标所在显示器的工作区(物理像素)
|
// 获取光标所在显示器的完整边界(含任务栏,物理像素)。
|
||||||
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx as i32, my as i32)
|
// 托盘图标位于任务栏上,菜单需在鼠标位置弹出,因此用屏幕边界而非工作区做 clamp,
|
||||||
.unwrap_or((0, 0, 1920, 1040));
|
// 否则会被工作区底部(任务栏顶部)截断,导致菜单整体被推到任务栏上方。
|
||||||
|
let (scr_left, scr_top, scr_right, scr_bottom) = get_monitor_bounds_at_point(mx as i32, my as i32)
|
||||||
|
.unwrap_or((0, 0, 1920, 1080));
|
||||||
|
|
||||||
// 光标所在显示器的 DPI:菜单宽度按物理像素换算
|
// 光标所在显示器的 DPI:菜单宽度按物理像素换算
|
||||||
let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96);
|
let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96);
|
||||||
let scale = dpi as f64 / 96.0;
|
let scale = dpi as f64 / 96.0;
|
||||||
let menu_w_px = MENU_W * scale;
|
let menu_w_px = MENU_W * scale;
|
||||||
|
|
||||||
// 水平:菜单左边缘对齐鼠标 X(向右延伸),超出右边界则左移(物理坐标)
|
// 水平:菜单左边缘对齐鼠标 X(向右延伸),超出屏幕右边界则左移(物理坐标)
|
||||||
let x = mx.max(wa_left as f64).min(wa_right as f64 - menu_w_px);
|
let x = mx.max(scr_left as f64).min(scr_right as f64 - menu_w_px);
|
||||||
|
|
||||||
// 保存布局参数(全部物理坐标 + scale,供 tray_menu_ready 换算前端上报的逻辑高度)
|
// 保存布局参数(全部物理坐标 + scale,供 tray_menu_ready 换算前端上报的逻辑高度)
|
||||||
{
|
{
|
||||||
let mut layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
|
let mut layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
*layout = Some((x, tray_top, wa_top as f64, wa_bottom as f64, scale));
|
*layout = Some((x, my, scr_top as f64, scr_bottom as f64, scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录显示时间,用于失焦防抖
|
// 记录显示时间,用于失焦防抖
|
||||||
@@ -281,11 +308,26 @@ pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64,
|
|||||||
precreate_tray_menu_window(app);
|
precreate_tray_menu_window(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口)
|
// 1) 立即发送基础状态(不含代理节点,纯本地查询),菜单秒开——
|
||||||
|
// 前端测量内容高度后调用 tray_menu_ready 显示窗口。
|
||||||
|
let base = get_base_tray_state(app);
|
||||||
|
let _ = app.emit(crate::constants::events::TRAY_MENU_SHOW, base);
|
||||||
|
|
||||||
|
// 2) 异步拉取完整状态(含 mihomo /proxies 节点),到达后经 TRAY_MENU_STATE_UPDATED
|
||||||
|
// 补充给前端(前端在窗口可见时重新测量调整尺寸)。
|
||||||
|
// mihomo API 慢/挂起时不再阻塞菜单显示;超时 5s 放弃(保持基础状态)。
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let state = get_tray_menu_state(&app_clone).await;
|
let state = match tokio::time::timeout(
|
||||||
let _ = app_clone.emit(crate::constants::events::TRAY_MENU_SHOW, state);
|
Duration::from_millis(5000),
|
||||||
|
get_full_tray_state(&app_clone),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let _ = app_clone.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +344,22 @@ async fn refresh_and_emit_state(app: &AppHandle) {
|
|||||||
let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
|
let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 显示主窗口并强制置为前台。
|
||||||
|
/// Tauri 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台;
|
||||||
|
/// 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。
|
||||||
|
pub fn focus_main_window(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
match window.hwnd() {
|
||||||
|
Ok(hwnd) => crate::win32_util::force_foreground(hwnd.0 as isize),
|
||||||
|
Err(_) => {
|
||||||
|
window.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Tauri 命令 =====
|
// ===== Tauri 命令 =====
|
||||||
|
|
||||||
/// 执行菜单项动作(统一入口)
|
/// 执行菜单项动作(统一入口)
|
||||||
@@ -338,6 +396,21 @@ pub async fn tray_menu_action(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"system_proxy_toggle" => {
|
||||||
|
// 根据注册表当前真实状态切换(不依赖 settings.json 缓存,避免与主界面不同步)
|
||||||
|
let mihomo = app.state::<MihomoManager>();
|
||||||
|
if crate::mihomo_manager::get_system_proxy_windows() {
|
||||||
|
if let Err(e) = mihomo.disable_system_proxy() {
|
||||||
|
crate::logger::log_error("tray", &format!("关闭系统代理失败: {}", e));
|
||||||
|
send_notification(&app, "系统代理关闭失败", &e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Err(e) = mihomo.enable_system_proxy() {
|
||||||
|
crate::logger::log_error("tray", &format!("开启系统代理失败: {}", e));
|
||||||
|
send_notification(&app, "系统代理开启失败", &e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"osd_toggle" => {
|
"osd_toggle" => {
|
||||||
let _ = app.emit(crate::constants::events::TRAY_TOGGLE_OSD, ());
|
let _ = app.emit(crate::constants::events::TRAY_TOGGLE_OSD, ());
|
||||||
}
|
}
|
||||||
@@ -348,18 +421,12 @@ pub async fn tray_menu_action(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"download_new" => {
|
"download_new" => {
|
||||||
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
|
focus_main_window(&app);
|
||||||
window.show().ok();
|
|
||||||
window.set_focus().ok();
|
|
||||||
}
|
|
||||||
let _ = app.emit(crate::constants::events::TRAY_NEW_DOWNLOAD, ());
|
let _ = app.emit(crate::constants::events::TRAY_NEW_DOWNLOAD, ());
|
||||||
hide_tray_menu(&app);
|
hide_tray_menu(&app);
|
||||||
}
|
}
|
||||||
"settings" => {
|
"settings" => {
|
||||||
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
|
focus_main_window(&app);
|
||||||
window.show().ok();
|
|
||||||
window.set_focus().ok();
|
|
||||||
}
|
|
||||||
let _ = app.emit(crate::constants::events::TRAY_OPEN_SETTINGS, ());
|
let _ = app.emit(crate::constants::events::TRAY_OPEN_SETTINGS, ());
|
||||||
hide_tray_menu(&app);
|
hide_tray_menu(&app);
|
||||||
}
|
}
|
||||||
@@ -370,9 +437,20 @@ pub async fn tray_menu_action(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新状态并发送给前端(quit 除外,quit 后进程已退出)
|
// 按动作类型选择性刷新,避免无关动作(OSD 开关/跳转设置)也全量请求 mihomo API:
|
||||||
if action != "quit" {
|
// - 代理相关动作:全量刷新(节点列表/延迟可能已变化)
|
||||||
refresh_and_emit_state(&app).await;
|
// - Kernel/OSD:仅刷新本地基础状态(monitor 运行状态)
|
||||||
|
// - 跳转/退出:不刷新(quit 后进程已退出)
|
||||||
|
match action.as_str() {
|
||||||
|
"proxy_enable" | "proxy_disable" | "proxy_refresh" | "proxy_select_node"
|
||||||
|
| "system_proxy_toggle" => {
|
||||||
|
refresh_and_emit_state(&app).await;
|
||||||
|
}
|
||||||
|
"kernel_restart" | "osd_toggle" => {
|
||||||
|
let state = get_base_tray_state(&app);
|
||||||
|
let _ = app.emit(crate::constants::events::TRAY_MENU_STATE_UPDATED, state);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -392,9 +470,9 @@ pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(),
|
|||||||
let win = app.get_webview_window(TRAY_MENU_LABEL)
|
let win = app.get_webview_window(TRAY_MENU_LABEL)
|
||||||
.ok_or("tray-menu window not found")?;
|
.ok_or("tray-menu window not found")?;
|
||||||
|
|
||||||
let (x, tray_top, wa_top, wa_bottom, scale) = {
|
let (x, cursor_y, scr_top, scr_bottom, scale) = {
|
||||||
let layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
|
let layout = LAST_MENU_LAYOUT.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
layout.unwrap_or((0.0, 1040.0, 0.0, 1040.0, 1.0))
|
layout.unwrap_or((0.0, 1040.0, 0.0, 1080.0, 1.0))
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将内容高度限制在合理范围内(前端上报为逻辑像素)
|
// 将内容高度限制在合理范围内(前端上报为逻辑像素)
|
||||||
@@ -408,14 +486,22 @@ pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(),
|
|||||||
height: win_h_px as u32,
|
height: win_h_px as u32,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 垂直:菜单下边缘紧贴托盘图标顶部(向上弹出,物理坐标)
|
// 垂直:菜单底部对齐鼠标点击位置(向上弹出,物理坐标)。
|
||||||
let y = (tray_top - win_h_px).max(wa_top).min(wa_bottom - win_h_px);
|
// 用屏幕边界而非工作区 clamp,使菜单贴近/覆盖任务栏上的鼠标位置,
|
||||||
|
// 而不是被工作区底部(任务栏顶部)截断后整体出现在任务栏上方。
|
||||||
|
let y = (cursor_y - win_h_px).max(scr_top).min(scr_bottom - win_h_px);
|
||||||
|
|
||||||
let pos = tauri::Position::Physical(tauri::PhysicalPosition {
|
let pos = tauri::Position::Physical(tauri::PhysicalPosition {
|
||||||
x: x as i32,
|
x: x as i32,
|
||||||
y: y as i32,
|
y: y as i32,
|
||||||
});
|
});
|
||||||
let _ = win.set_position(pos);
|
let _ = win.set_position(pos);
|
||||||
|
// 以实际显示时刻重置失焦防抖起点:右键到此处可能间隔较久(前端测量 + 基础状态),
|
||||||
|
// 若沿用右键时刻,WebView2 显示瞬间的焦点抖动会被误判为失焦而立即隐藏菜单。
|
||||||
|
{
|
||||||
|
let mut t = LAST_SHOW_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
*t = Some(Instant::now());
|
||||||
|
}
|
||||||
let _ = win.show();
|
let _ = win.show();
|
||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
|
|
||||||
@@ -446,33 +532,57 @@ async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取节点列表
|
// 2. 获取节点并择优:遵循自动切换的目标组与地区筛选(关闭自动切换时退化为主组 + 全量节点)
|
||||||
let proxies = mihomo.get_proxies().await?;
|
let settings = mihomo.load_settings();
|
||||||
let (group, nodes, _now) = parse_main_group(&proxies)
|
|
||||||
.ok_or_else(|| "无法解析代理组".to_string())?;
|
|
||||||
|
|
||||||
if !nodes.is_empty() {
|
match crate::mihomo_manager::pick_best(&mihomo, &settings).await {
|
||||||
// 3. 并行测试所有节点延迟
|
Ok(Some((group, name, delay, now))) => {
|
||||||
let best = test_and_select_best(&mihomo, &group, &nodes).await;
|
// 与自动切换一样,仅当当前节点不是最优时才切换
|
||||||
|
if name != now {
|
||||||
// 4. 发送通知
|
let _ = mihomo.select_proxy(&group, &name).await;
|
||||||
match &best {
|
|
||||||
Some((name, delay)) => {
|
|
||||||
send_notification(
|
|
||||||
app,
|
|
||||||
"代理已开启",
|
|
||||||
&format!("当前节点: {} ({}ms)", name, delay),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => {
|
send_notification(app, "代理已开启", &format!("当前节点: {} ({}ms)", name, delay));
|
||||||
send_notification(app, "代理已开启", "所有节点均超时,未自动选择");
|
}
|
||||||
|
Ok(None) | Err(_) => {
|
||||||
|
// 区分「无可用节点」与「全部超时」两种情况
|
||||||
|
let unavailable = {
|
||||||
|
let proxies = mihomo.get_proxies().await.ok();
|
||||||
|
let group_name: Option<String> = if !settings.auto_switch_group.is_empty() {
|
||||||
|
Some(settings.auto_switch_group.clone())
|
||||||
|
} else {
|
||||||
|
proxies
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("proxies"))
|
||||||
|
.and_then(|m| m.as_object())
|
||||||
|
.and_then(|map| {
|
||||||
|
let mut names = map.iter().filter(|(_, v)| {
|
||||||
|
v.get("type").and_then(|t| t.as_str()) == Some("Selector")
|
||||||
|
});
|
||||||
|
names.next().map(|(n, _)| n.clone())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let empty = group_name.map(|g| {
|
||||||
|
proxies
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("proxies"))
|
||||||
|
.and_then(|m| m.as_object())
|
||||||
|
.and_then(|map| map.get(&g))
|
||||||
|
.and_then(|v| v.get("all"))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
}).unwrap_or(true);
|
||||||
|
empty
|
||||||
|
};
|
||||||
|
if unavailable {
|
||||||
|
send_notification(app, "代理已开启", "无可用节点");
|
||||||
|
} else {
|
||||||
|
send_notification(app, "代理已开启", "所有候选节点均超时,未自动选择");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
send_notification(app, "代理已开启", "无可用节点");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 开启系统代理
|
// 3. 开启系统代理
|
||||||
mihomo.enable_system_proxy()?;
|
mihomo.enable_system_proxy()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -495,40 +605,6 @@ async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
// ===== 择优选择节点 =====
|
// ===== 择优选择节点 =====
|
||||||
|
|
||||||
/// 并行测试所有节点延迟,选择最低延迟节点
|
|
||||||
async fn test_and_select_best(
|
|
||||||
mihomo: &MihomoManager,
|
|
||||||
group: &str,
|
|
||||||
nodes: &[String],
|
|
||||||
) -> Option<(String, u32)> {
|
|
||||||
use futures_util::future::join_all;
|
|
||||||
|
|
||||||
let futures: Vec<_> = nodes
|
|
||||||
.iter()
|
|
||||||
.map(|name| async move {
|
|
||||||
let delay = mihomo
|
|
||||||
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
(name.clone(), delay)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let results = join_all(futures).await;
|
|
||||||
|
|
||||||
let best = results
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(name, delay)| delay.map(|d| (name, d)))
|
|
||||||
.filter(|(_, d)| *d > 0)
|
|
||||||
.min_by_key(|(_, d)| *d);
|
|
||||||
|
|
||||||
if let Some((ref name, _)) = best {
|
|
||||||
let _ = mihomo.select_proxy(group, name).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
best
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 并行测试所有节点延迟(更新 mihomo 内部 history)
|
/// 并行测试所有节点延迟(更新 mihomo 内部 history)
|
||||||
async fn test_all_delays(app: &AppHandle) {
|
async fn test_all_delays(app: &AppHandle) {
|
||||||
let mihomo = app.state::<MihomoManager>();
|
let mihomo = app.state::<MihomoManager>();
|
||||||
@@ -647,31 +723,17 @@ pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
|
|||||||
button: MouseButton::Left,
|
button: MouseButton::Left,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// 左键:显示主窗口
|
// 左键:显示主窗口(强制置前,绕过前台锁定)
|
||||||
if let Some(window) = app.get_webview_window(crate::constants::windows::MAIN) {
|
focus_main_window(&app);
|
||||||
window.show().ok();
|
|
||||||
window.set_focus().ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TrayIconEvent::Click {
|
TrayIconEvent::Click {
|
||||||
button: MouseButton::Right,
|
button: MouseButton::Right,
|
||||||
position,
|
position,
|
||||||
rect,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// 右键:显示自定义菜单窗口(使用事件中的精确坐标)
|
// 右键:显示自定义菜单窗口(使用事件中的鼠标坐标)
|
||||||
let cursor = (position.x, position.y);
|
let cursor = (position.x, position.y);
|
||||||
// 从 Rect 的 Position/Size 枚举中提取物理像素值
|
show_tray_menu(&app, cursor);
|
||||||
let (rx, ry) = match rect.position {
|
|
||||||
tauri::Position::Physical(p) => (p.x as f64, p.y as f64),
|
|
||||||
tauri::Position::Logical(p) => (p.x, p.y),
|
|
||||||
};
|
|
||||||
let (_rw, rh) = match rect.size {
|
|
||||||
tauri::Size::Physical(s) => (s.width as f64, s.height as f64),
|
|
||||||
tauri::Size::Logical(s) => (s.width, s.height),
|
|
||||||
};
|
|
||||||
let tray_r = (rx, ry, _rw, rh);
|
|
||||||
show_tray_menu(&app, cursor, tray_r);
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
//! 应用自更新模块。
|
||||||
|
//! 更新源为自建 Gitea:`https://gitea.atie.fun/LFeng/Thing` 的 release 资产。
|
||||||
|
//! - 便携版(无 unins000.exe 且不在 Program Files):下载新 thing.exe → update.bat 覆盖重启
|
||||||
|
//! - 安装版(NSIS):下载新 setup.exe → 提权静默安装 /S
|
||||||
|
//! - ThingHK 内核:下载由前端下载模块完成 → apply 命令 need_stop 等待确认 → 解压覆盖
|
||||||
|
//! {app_data}/monitor/cores/ThingHK.exe(与代理模块 mihomo 内核更新同模式)
|
||||||
|
use serde::Serialize;
|
||||||
|
use specta::Type;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{atomic::{AtomicBool, Ordering}, Mutex};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::sync::{oneshot, watch};
|
||||||
|
|
||||||
|
use crate::constants::events::UPDATE_PROGRESS;
|
||||||
|
|
||||||
|
/// 用户主动取消 ThingHK 更新的标记错误信息(前端据此静默处理,不弹错误 toast)
|
||||||
|
const THINGHK_UPDATE_CANCELLED: &str = "更新已取消";
|
||||||
|
|
||||||
|
/// ThingHK 内核更新的跨命令状态:apply 过程中 need_stop 阶段等待前端确认。
|
||||||
|
/// 与 MihomoManager 的 install_confirm/kernel_cancel 同构。
|
||||||
|
pub struct ThinghkUpdateState {
|
||||||
|
/// need_stop 等待阶段的确认通道(前端调 confirm 命令时唤醒 apply 继续)
|
||||||
|
confirm_tx: Mutex<Option<oneshot::Sender<()>>>,
|
||||||
|
/// 取消标志 + 唤醒通道(前端调 cancel 命令时置位,apply 等待循环立即返回)
|
||||||
|
cancel_flag: AtomicBool,
|
||||||
|
cancel_tx: watch::Sender<bool>,
|
||||||
|
cancel_rx: watch::Receiver<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThinghkUpdateState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, rx) = watch::channel(false);
|
||||||
|
Self {
|
||||||
|
confirm_tx: Mutex::new(None),
|
||||||
|
cancel_flag: AtomicBool::new(false),
|
||||||
|
cancel_tx: tx,
|
||||||
|
cancel_rx: rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端已停止监控内核,唤醒 apply 继续解压替换
|
||||||
|
fn confirm(&self) {
|
||||||
|
if let Some(tx) = self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消更新:置位取消标志并唤醒 apply 等待循环
|
||||||
|
fn cancel(&self) {
|
||||||
|
self.cancel_flag.store(true, Ordering::SeqCst);
|
||||||
|
let _ = self.cancel_tx.send(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进入新的 apply 流程前复位取消标志
|
||||||
|
fn reset(&self) {
|
||||||
|
self.cancel_flag.store(false, Ordering::SeqCst);
|
||||||
|
let _ = self.cancel_tx.send(false);
|
||||||
|
*self.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发布仓库(Gitea)
|
||||||
|
const GITEA_REPO: &str = "LFeng/Thing";
|
||||||
|
const GITEA_BASE: &str = "https://gitea.atie.fun";
|
||||||
|
|
||||||
|
/// release 中的一个资产
|
||||||
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查更新的结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateCheckResult {
|
||||||
|
pub current_version: String,
|
||||||
|
pub latest_version: String,
|
||||||
|
pub has_update: bool,
|
||||||
|
/// portable | installed
|
||||||
|
pub install_type: String,
|
||||||
|
pub release_name: String,
|
||||||
|
pub release_body: String,
|
||||||
|
pub assets: Vec<UpdateAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新进度事件载荷(与内核安装进度同构,独立事件便于 UI 区分)
|
||||||
|
#[derive(Serialize, Clone, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateProgress {
|
||||||
|
pub stage: String,
|
||||||
|
pub percent: u8,
|
||||||
|
#[specta(type = f64)]
|
||||||
|
pub downloaded_bytes: u64,
|
||||||
|
#[specta(type = Option<f64>)]
|
||||||
|
pub total_bytes: Option<u64>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 版本比较 ----------
|
||||||
|
|
||||||
|
/// 解析 vX.Y.Z 为数字元组用于比较;解析失败返回 (0,0,0)
|
||||||
|
fn parse_version(v: &str) -> (u32, u32, u32) {
|
||||||
|
let s = v.trim().trim_start_matches('v');
|
||||||
|
let mut parts = s.split('.');
|
||||||
|
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
let patch = parts
|
||||||
|
.next()
|
||||||
|
.map(|p| p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>())
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(major, minor, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_gt(a: &str, b: &str) -> bool {
|
||||||
|
parse_version(a) > parse_version(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Gitea API ----------
|
||||||
|
|
||||||
|
struct LatestRelease {
|
||||||
|
tag_name: String,
|
||||||
|
name: String,
|
||||||
|
body: String,
|
||||||
|
assets: Vec<UpdateAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_latest_release() -> Result<LatestRelease, String> {
|
||||||
|
let url = format!("{}/api/v1/repos/{}/releases/latest", GITEA_BASE, GITEA_REPO);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "thing-app")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("请求 Gitea API 失败: {}", e))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Gitea API 返回 HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("解析 Gitea 响应失败: {}", e))?;
|
||||||
|
let mut assets = Vec::new();
|
||||||
|
if let Some(list) = json.get("assets").and_then(|v| v.as_array()) {
|
||||||
|
for a in list {
|
||||||
|
if let (Some(name), Some(url)) = (
|
||||||
|
a.get("name").and_then(|v| v.as_str()),
|
||||||
|
a.get("browser_download_url").and_then(|v| v.as_str()),
|
||||||
|
) {
|
||||||
|
assets.push(UpdateAsset {
|
||||||
|
name: name.to_string(),
|
||||||
|
size: a.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||||
|
browser_download_url: url.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LatestRelease {
|
||||||
|
tag_name: json.get("tag_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
name: json.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
body: json.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
assets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 解压 ----------
|
||||||
|
// ThingHK 内核更新包由前端下载模块负责下载(同 mihomo),此处仅解压替换。
|
||||||
|
|
||||||
|
/// 用 zip crate 解压(纯 Rust,避免 PowerShell 执行策略问题)
|
||||||
|
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
|
||||||
|
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?;
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut entry = archive
|
||||||
|
.by_index(i)
|
||||||
|
.map_err(|e| format!("读取条目失败: {}", e))?;
|
||||||
|
let outpath = match entry.enclosed_name() {
|
||||||
|
Some(p) => dest.join(p),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if entry.is_dir() {
|
||||||
|
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = outpath.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = entry.read(&mut buf).map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 安装类型 / ShellExecute ----------
|
||||||
|
|
||||||
|
/// 判断当前是便携版还是安装版。
|
||||||
|
/// NSIS 安装会在程序目录生成 unins000.exe;MSI 通常安装到 Program Files。
|
||||||
|
fn is_installed_version() -> bool {
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
if dir.join("unins000.exe").exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let p = dir.to_string_lossy().to_lowercase();
|
||||||
|
if p.contains("program files") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过 ShellExecuteW 启动程序/文档(绕过 Job Object,脱离主进程生命周期)
|
||||||
|
fn shell_execute(verb: &str, file: &Path, params: &str, show: i32) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows_sys::Win32::UI::Shell::ShellExecuteW;
|
||||||
|
let file_w: Vec<u16> = file.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||||
|
let verb_w: Vec<u16> = verb.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let params_w: Vec<u16> = params.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let res = unsafe {
|
||||||
|
ShellExecuteW(
|
||||||
|
0 as isize,
|
||||||
|
verb_w.as_ptr(),
|
||||||
|
file_w.as_ptr(),
|
||||||
|
params_w.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
show,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if (res as isize) <= 32 {
|
||||||
|
return Err(format!("ShellExecuteW 失败 (code={})", res));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (verb, file, params, show);
|
||||||
|
Err("仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 便携版:写 update.bat 等待主进程退出 → 覆盖 exe → 重新启动
|
||||||
|
fn apply_portable_update(new_exe: &Path) -> Result<(), String> {
|
||||||
|
let cur_exe = std::env::current_exe().map_err(|e| format!("获取当前程序路径失败: {}", e))?;
|
||||||
|
let cur_dir = cur_exe.parent().ok_or("无法确定程序目录".to_string())?;
|
||||||
|
let bat_path = cur_dir.join("update.bat");
|
||||||
|
let script = format!(
|
||||||
|
"@echo off\r\n\
|
||||||
|
:wait\r\n\
|
||||||
|
tasklist /FI \"IMAGENAME eq thing.exe\" 2>nul | findstr /i \"thing.exe\" >nul\r\n\
|
||||||
|
if not errorlevel 1 (\r\n\
|
||||||
|
ping -n 2 127.0.0.1 >nul\r\n\
|
||||||
|
goto wait\r\n\
|
||||||
|
)\r\n\
|
||||||
|
copy /y \"{new}\" \"{cur}\" >nul\r\n\
|
||||||
|
if errorlevel 1 exit /b 1\r\n\
|
||||||
|
start \"\" \"{cur}\"\r\n\
|
||||||
|
del \"{new}\" >nul 2>nul\r\n\
|
||||||
|
del \"%~f0\" >nul 2>nul\r\n",
|
||||||
|
new = new_exe.display(),
|
||||||
|
cur = cur_exe.display()
|
||||||
|
);
|
||||||
|
fs::write(&bat_path, script).map_err(|e| format!("写入更新脚本失败: {}", e))?;
|
||||||
|
// 用 cmd /c 启动 bat 并隐藏窗口;ShellExecute 启动的进程不属于本进程 Job,
|
||||||
|
// 主进程退出后 update.bat 仍能继续执行
|
||||||
|
let windir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
|
||||||
|
let cmd_exe = Path::new(&windir).join("System32").join("cmd.exe");
|
||||||
|
shell_execute("open", &cmd_exe, &format!("/c \"{}\"", bat_path.display()), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 命令 ----------
|
||||||
|
|
||||||
|
/// 获取当前应用版本
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn app_version(app: AppHandle) -> String {
|
||||||
|
app.package_info().version.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 Gitea 最新 release,返回版本对比与可用资产
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn update_check(app: AppHandle) -> Result<UpdateCheckResult, String> {
|
||||||
|
let latest = fetch_latest_release().await?;
|
||||||
|
let latest_version = latest.tag_name.trim_start_matches('v').to_string();
|
||||||
|
let current_version = app.package_info().version.to_string();
|
||||||
|
let has_update = version_gt(&latest_version, ¤t_version);
|
||||||
|
Ok(UpdateCheckResult {
|
||||||
|
current_version,
|
||||||
|
latest_version,
|
||||||
|
has_update,
|
||||||
|
install_type: if is_installed_version() { "installed".into() } else { "portable".into() },
|
||||||
|
release_name: latest.name,
|
||||||
|
release_body: latest.body,
|
||||||
|
assets: latest.assets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新应用本体(安装阶段)。下载由前端下载模块完成,本命令接收已下载的
|
||||||
|
/// 安装包路径(便携版 thing_{v}_x64.exe / 安装版 thing_{v}_x64-setup.exe)。
|
||||||
|
/// 便携版:copy 到临时目录 → update.bat 覆盖重启;
|
||||||
|
/// 安装版:copy 到临时目录 → 提权静默安装 /S。
|
||||||
|
/// 调用返回前会触发应用退出。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn update_install(app: AppHandle, downloaded_path: String) -> Result<(), String> {
|
||||||
|
let src = PathBuf::from(&downloaded_path);
|
||||||
|
if !src.exists() {
|
||||||
|
return Err(format!("下载文件不存在: {}", downloaded_path));
|
||||||
|
}
|
||||||
|
let installed = is_installed_version();
|
||||||
|
// copy 到临时目录:与 update.bat / 安装器解耦,随后即可删除下载目录中的源文件
|
||||||
|
let temp_dir = std::env::temp_dir().join("thing-update");
|
||||||
|
fs::create_dir_all(&temp_dir).map_err(|e| format!("创建临时目录失败: {}", e))?;
|
||||||
|
let file_name = src
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "thing_update.exe".into());
|
||||||
|
let dest = temp_dir.join(&file_name);
|
||||||
|
fs::copy(&src, &dest).map_err(|e| format!("复制安装包到临时目录失败: {}", e))?;
|
||||||
|
// 临时副本就绪后清理下载目录中的源文件(失败不影响更新流程)
|
||||||
|
let _ = fs::remove_file(&src);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "applying".into(),
|
||||||
|
percent: 100,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: if installed { "正在启动安装程序...".into() } else { "正在替换程序文件...".into() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if installed {
|
||||||
|
// 提权静默安装 /S;UAC 确认期间主进程已退出,安装器可正常覆盖
|
||||||
|
shell_execute("runas", &dest, "/S", 0)?;
|
||||||
|
} else {
|
||||||
|
apply_portable_update(&dest)?;
|
||||||
|
}
|
||||||
|
// 延迟退出,确保 ShellExecute 已拉起子进程
|
||||||
|
app.exit(0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用 ThingHK 内核更新:下载阶段已由下载模块完成,本命令仅做
|
||||||
|
/// need_stop(等待前端停止监控内核并确认)→ 解压 → 替换。
|
||||||
|
/// 进度通过 UPDATE_PROGRESS 事件上报,前端据 need_stop 弹出确认对话框。
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn update_thinghk_apply(
|
||||||
|
app: AppHandle,
|
||||||
|
state: tauri::State<'_, ThinghkUpdateState>,
|
||||||
|
zip_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.reset();
|
||||||
|
let result = update_thinghk_apply_inner(&app, &state, PathBuf::from(&zip_path)).await;
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
// 取消是用户主动行为,静默返回即可;其余失败 emit error 阶段避免前端进度卡死
|
||||||
|
if e != THINGHK_UPDATE_CANCELLED {
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "error".into(),
|
||||||
|
percent: 0,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: e.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_thinghk_apply_inner(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &ThinghkUpdateState,
|
||||||
|
zip_path: PathBuf,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if !zip_path.exists() {
|
||||||
|
return Err(format!("下载文件不存在: {}", zip_path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// need_stop:等待前端停止监控内核并确认(exe 被占用会导致覆盖失败)。
|
||||||
|
// 确认/取消由 confirm/cancel 命令跨命令唤醒(与 mihomo need_stop 同构)。
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "need_stop".into(),
|
||||||
|
percent: 90,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: "需要停止监控内核才能继续安装".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (tx, mut rx) = oneshot::channel::<()>();
|
||||||
|
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||||
|
let mut cancel_rx = state.cancel_rx.clone();
|
||||||
|
loop {
|
||||||
|
if state.cancel_flag.load(Ordering::SeqCst) {
|
||||||
|
*state.confirm_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
return Err(THINGHK_UPDATE_CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut rx => break,
|
||||||
|
_ = cancel_rx.changed() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压阶段
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "extracting".into(),
|
||||||
|
percent: 95,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: "正在解压内核...".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let temp_dir = std::env::temp_dir().join("thing-update");
|
||||||
|
let extract_dir = temp_dir.join("thinghk_extract");
|
||||||
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
|
extract_zip(&zip_path, &extract_dir)?;
|
||||||
|
|
||||||
|
// 在解压目录中查找 ThingHK.exe
|
||||||
|
let exe_path = find_thinghk_exe(&extract_dir).ok_or("内核包中未找到 ThingHK.exe".to_string())?;
|
||||||
|
let app_data = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("获取数据目录失败: {}", e))?;
|
||||||
|
let cores_dir = app_data.join("monitor").join("cores");
|
||||||
|
fs::create_dir_all(&cores_dir).map_err(|e| format!("创建内核目录失败: {}", e))?;
|
||||||
|
fs::copy(&exe_path, cores_dir.join("ThingHK.exe"))
|
||||||
|
.map_err(|e| format!("覆盖内核文件失败(请确认监控模块已停止): {}", e))?;
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
let _ = fs::remove_file(&zip_path);
|
||||||
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
|
let _ = app.emit(
|
||||||
|
UPDATE_PROGRESS,
|
||||||
|
UpdateProgress {
|
||||||
|
stage: "done".into(),
|
||||||
|
percent: 100,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
message: "ThingHK 内核更新完成".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端已停止监控内核,确认继续解压替换(唤醒 need_stop 等待)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn update_thinghk_confirm(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||||||
|
state.confirm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消 ThingHK 内核更新(need_stop 等待阶段有效:唤醒 apply 以「已取消」返回,zip 保留便于重试)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn update_thinghk_cancel(state: tauri::State<'_, ThinghkUpdateState>) -> Result<(), String> {
|
||||||
|
state.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_thinghk_exe(dir: &Path) -> Option<PathBuf> {
|
||||||
|
if let Ok(entries) = fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if let Some(found) = find_thinghk_exe(&path) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
} else if path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("ThingHK.exe"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -60,6 +60,30 @@ pub fn get_work_area_at_point(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取指定点所在显示器的完整边界(含任务栏),返回 (left, top, right, bottom) 物理像素。
|
||||||
|
/// 与 get_work_area_at_point 不同,这里用 rcMonitor 而非 rcWork,
|
||||||
|
/// 用于需要在鼠标位置弹出、允许贴近/覆盖任务栏的场景(如托盘菜单)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn get_monitor_bounds_at_point(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
|
||||||
|
use windows_sys::Win32::Foundation::POINT;
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pt = POINT { x, y };
|
||||||
|
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
|
||||||
|
let mut mi: MONITORINFO = unsafe { std::mem::zeroed() };
|
||||||
|
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||||
|
unsafe {
|
||||||
|
if GetMonitorInfoW(hmon, &mut mi) != 0 {
|
||||||
|
let rc = mi.rcMonitor;
|
||||||
|
Some((rc.left, rc.top, rc.right, rc.bottom))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取指定点所在显示器的有效 DPI。
|
/// 获取指定点所在显示器的有效 DPI。
|
||||||
/// scale factor = dpi / 96。
|
/// scale factor = dpi / 96。
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -81,6 +105,139 @@ pub fn get_dpi_for_point(x: i32, y: i32) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 强制将窗口置为前台(绕过 Windows 前台锁定限制)。
|
||||||
|
/// Tauri 的 set_focus 内部调用 SetForegroundWindow,受前台锁定(foreground lock)限制:
|
||||||
|
/// 本进程不拥有前台时调用会被系统忽略,导致已打开但被遮挡的窗口无法到前台。
|
||||||
|
/// 先模拟 Alt 键释放以重置前台锁定,再 SetForegroundWindow + BringWindowToTop。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn force_foreground(hwnd: isize) {
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
|
keybd_event, KEYEVENTF_KEYUP, VK_MENU,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{BringWindowToTop, SetForegroundWindow};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
keybd_event(VK_MENU as u8, 0, KEYEVENTF_KEYUP, 0);
|
||||||
|
let _ = SetForegroundWindow(hwnd);
|
||||||
|
let _ = BringWindowToTop(hwnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用 WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW 扩展样式到指定窗口。
|
||||||
|
/// 用于预览窗等不抢焦点的悬浮窗:窗口可接收鼠标交互但不激活、不进 Alt-Tab。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn apply_no_activate(hwnd: isize) {
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
||||||
|
let new_ex = ex | (WS_EX_NOACTIVATE as isize) | (WS_EX_TOOLWINDOW as isize);
|
||||||
|
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按需添加/移除 WS_EX_NOACTIVATE(不动 WS_EX_TOOLWINDOW)。
|
||||||
|
///
|
||||||
|
/// 取词悬浮窗默认不激活;但弹窗里的原文编辑框需要键盘焦点——NOACTIVATE 窗口
|
||||||
|
/// 永远拿不到焦点,根本无法输入。用户点击编辑区时移除该样式并强制激活
|
||||||
|
/// (见 [`force_foreground`]),弹窗隐藏时恢复,保证下一次划词仍不抢焦点。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn set_no_activate(hwnd: isize, no_activate: bool) {
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
||||||
|
let new_ex = if no_activate {
|
||||||
|
ex | (WS_EX_NOACTIVATE as isize)
|
||||||
|
} else {
|
||||||
|
ex & !(WS_EX_NOACTIVATE as isize)
|
||||||
|
};
|
||||||
|
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn set_no_activate(_hwnd: isize, _no_activate: bool) {}
|
||||||
|
|
||||||
|
/// 鼠标左键当前是否按下(GetAsyncKeyState,全局异步状态,无需窗口焦点)。
|
||||||
|
/// 供看护线程轮询检测"点击外部"(配合按下沿判定)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn is_left_button_down() -> bool {
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||||
|
|
||||||
|
const VK_LBUTTON: i32 = 0x01;
|
||||||
|
((unsafe { GetAsyncKeyState(VK_LBUTTON) } as u32) & 0x8000) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn is_left_button_down() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强制窗口应用系统圆角(DWMWCP_ROUND,Win11 约 8px)。
|
||||||
|
/// 带 WS_EX_NOACTIVATE 等扩展样式的悬浮窗系统不会自动圆角(呈现直角),
|
||||||
|
/// 与常规弹窗外观不一致;通过 DWMWA_WINDOW_CORNER_PREFERENCE 显式指定圆角偏好。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn apply_rounded_corners(hwnd: isize) {
|
||||||
|
use windows_sys::Win32::Graphics::Dwm::{
|
||||||
|
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let pref: i32 = DWMWCP_ROUND;
|
||||||
|
let _ = DwmSetWindowAttribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_WINDOW_CORNER_PREFERENCE as u32,
|
||||||
|
&pref as *const i32 as *const core::ffi::c_void,
|
||||||
|
std::mem::size_of::<i32>() as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以不激活方式显示窗口(SW_SHOWNOACTIVATE),避免抢走弹窗等前台窗口的焦点。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn show_no_activate(hwnd: isize) {
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
|
||||||
|
unsafe {
|
||||||
|
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏窗口(SW_HIDE)。供以原生方式显示(SW_SHOWNOACTIVATE)的悬浮窗兜底隐藏,
|
||||||
|
/// 与 Tauri 的 hide() 并存以确保任何路径下都被可靠隐藏。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn hide_window(hwnd: isize) {
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_HIDE};
|
||||||
|
unsafe {
|
||||||
|
ShowWindow(hwnd, SW_HIDE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统自上次用户输入(鼠标/键盘)以来的空闲时长(毫秒)。
|
||||||
|
/// 用于闲时执行耗时后台任务(如文件索引构建),避免与应用运行/用户操作抢 IO。
|
||||||
|
/// GetLastInputInfo 的 dwTime 与 GetTickCount 同为系统启动后毫秒数,用 wrapping 减法防回绕。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn get_idle_time_ms() -> u64 {
|
||||||
|
use windows_sys::Win32::System::SystemInformation::GetTickCount;
|
||||||
|
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
|
||||||
|
GetLastInputInfo, LASTINPUTINFO,
|
||||||
|
};
|
||||||
|
use std::mem::size_of;
|
||||||
|
|
||||||
|
let mut lii = LASTINPUTINFO {
|
||||||
|
cbSize: size_of::<LASTINPUTINFO>() as u32,
|
||||||
|
dwTime: 0,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
if GetLastInputInfo(&mut lii) != 0 {
|
||||||
|
return GetTickCount().wrapping_sub(lii.dwTime) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 非 Windows 平台空实现 =====
|
// ===== 非 Windows 平台空实现 =====
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
@@ -94,3 +251,24 @@ pub fn get_work_area_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)>
|
|||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn get_dpi_for_point(_x: i32, _y: i32) -> Option<u32> { None }
|
pub fn get_dpi_for_point(_x: i32, _y: i32) -> Option<u32> { None }
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn get_monitor_bounds_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)> { None }
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn force_foreground(_hwnd: isize) {}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn apply_no_activate(_hwnd: isize) {}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn apply_rounded_corners(_hwnd: isize) {}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn show_no_activate(_hwnd: isize) {}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn hide_window(_hwnd: isize) {}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn get_idle_time_ms() -> u64 { 0 }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user