fix: deliver TinySTT direct input with T icon and ding feedback
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 TinyTTS contributors
|
Copyright (c) 2026 TinySTT contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TinyTTS",
|
name: "TinySTT",
|
||||||
platforms: [.macOS("26.0")],
|
platforms: [.macOS("26.0")],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "TinyTTS", targets: ["TinyTTS"])
|
.executable(name: "TinySTT", targets: ["TinySTT"])
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "TinyTTS",
|
name: "TinySTT",
|
||||||
path: "Sources/TinyTTS",
|
path: "Sources/TinySTT",
|
||||||
swiftSettings: [.swiftLanguageMode(.v5)]
|
swiftSettings: [.swiftLanguageMode(.v5)]
|
||||||
),
|
),
|
||||||
.testTarget(name: "TinyTTSTests", dependencies: ["TinyTTS"], path: "Tests/TinyTTSTests", swiftSettings: [.swiftLanguageMode(.v5)])
|
.testTarget(name: "TinySTTTests", dependencies: ["TinySTT"], path: "Tests/TinySTTTests", swiftSettings: [.swiftLanguageMode(.v5)])
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="Resources/AppIcon-1024.png" width="128" alt="TinyTTS logo">
|
<img src="Resources/AppIcon-1024.png" width="128" alt="TinySTT logo">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# TinyTTS
|
# TinySTT
|
||||||
|
|
||||||
一个 Typeless 风格的开源精简版 macOS 语音输入工具。常驻右上角菜单栏,按一下右 Option 开始说话,再按一下,把文字写到当前输入框。
|
一个 Typeless 风格的开源精简版 macOS 语音输入工具。常驻右上角菜单栏,按一下右 Option 开始说话,再按一下,把文字写到当前输入框。
|
||||||
|
|
||||||
原生 Swift + SwiftUI,无第三方依赖,无账号、API key 或订阅。TinyTTS 名称沿用工程命名,功能是语音转文字(STT)。本项目与 Typeless 无隶属关系。
|
原生 Swift + SwiftUI,无第三方依赖,无账号、API key 或订阅。TinySTT 是全本地的语音转文字(STT)工具。本项目与 Typeless 无隶属关系。
|
||||||
|
|
||||||
## 默认交互
|
## 默认交互
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
- **保留识别标点,自动补句号**:不删除口头语、不改写、不合并内部空格;可关闭补句号。
|
- **保留识别标点,自动补句号**:不删除口头语、不改写、不合并内部空格;可关闭补句号。
|
||||||
- 实时字幕和音量条,不抢走输入焦点;可关闭字幕或固定到屏幕底部。
|
- 实时字幕和音量条,不抢走输入焦点;可关闭字幕或固定到屏幕底部。
|
||||||
- 默认连续 12 秒无声自动结束,可在设置中关闭。
|
- 默认连续 12 秒无声自动结束,可在设置中关闭。
|
||||||
|
- 完成自动输入后播放清脆的「叮」声,可在设置中关闭提示音。
|
||||||
|
|
||||||
菜单栏展示待机、准备、录音和处理中状态。录音时图标呼吸,处理时旋转;菜单里可开始/结束、取消、查看权限和模型状态、复制上次结果、打开设置或退出。待机时不运行图标动画。
|
菜单栏展示待机、准备、录音和处理中状态。录音时图标呼吸,处理时旋转;菜单里可开始/结束、取消、查看权限和模型状态、复制上次结果、打开设置或退出。待机时不运行图标动画。
|
||||||
|
|
||||||
@@ -26,20 +27,20 @@
|
|||||||
```bash
|
```bash
|
||||||
./scripts/test.sh
|
./scripts/test.sh
|
||||||
./scripts/build.sh
|
./scripts/build.sh
|
||||||
open dist/TinyTTS.app
|
open dist/TinySTT.app
|
||||||
```
|
```
|
||||||
|
|
||||||
产物为 `dist/TinyTTS.app`,构建脚本会组装图标、Info.plist 并验证代码签名。无需 `.xcodeproj`,也无需 `swift run`。请从 `.app` 启动,以正确关联系统权限。
|
产物为 `dist/TinySTT.app`,构建脚本会组装图标、Info.plist 并验证代码签名。无需 `.xcodeproj`,也无需 `swift run`。请从 `.app` 启动,以正确关联系统权限。
|
||||||
|
|
||||||
日常使用时退出旧版本,将 app 放入 `/Applications` 后再启动;在通用设置中开启登录启动。系统若要求批准登录项,需要到系统设置确认。
|
日常使用时退出旧版本,将 app 放入 `/Applications` 后再启动;在通用设置中开启登录启动。系统若要求批准登录项,需要到系统设置确认。
|
||||||
|
|
||||||
### 固定签名身份
|
### 固定签名身份
|
||||||
|
|
||||||
脚本依次选择 `TINYTTS_SIGN_IDENTITY`、可用 Apple Development 身份、TinyTTS Dev 身份,否则使用 ad-hoc 签名并发出提示。
|
脚本依次选择 `TINYSTT_SIGN_IDENTITY`、可用 Apple Development 身份、TinySTT Dev 身份,否则使用 ad-hoc 签名并发出提示。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 已有签名身份时
|
# 已有签名身份时
|
||||||
TINYTTS_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh
|
TINYSTT_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh
|
||||||
|
|
||||||
# 没有身份时,可在自己的终端运行,导入本地开发证书
|
# 没有身份时,可在自己的终端运行,导入本地开发证书
|
||||||
./scripts/setup-signing.sh
|
./scripts/setup-signing.sh
|
||||||
@@ -49,24 +50,24 @@ TINYTTS_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh
|
|||||||
|
|
||||||
## 首次使用
|
## 首次使用
|
||||||
|
|
||||||
1. 启动 app,允许麦克风,在「隐私与安全性 → 辅助功能」中允许 TinyTTS。若系统提示输入监控,也需在相应页面授权。辅助功能授权后会自动重试热键。
|
1. 启动 app,允许麦克风,在「隐私与安全性 → 辅助功能」中允许 TinySTT。若系统提示输入监控,也需在相应页面授权。辅助功能授权后会自动重试热键。
|
||||||
2. 打开「设置与测试语音 → 语音」,检查模型状态。首次下载需要网络,下载完成后的识别在本机进行;失败时可重试。
|
2. 打开「设置与测试语音 → 语音」,检查模型状态。首次下载需要网络,下载完成后的识别在本机进行;失败时可重试。
|
||||||
3. 点「开始测试」检查实时识别,结束后查看结果。此模式不向任何应用写入文字。
|
3. 点「开始测试」检查实时识别,结束后查看结果。此模式不向任何应用写入文字。
|
||||||
4. 回到目标输入框,按右 Option;首次若显示「正在准备本地识别」,等变为「聆听中」再说话,再按一次结束并写入。
|
4. 回到目标输入框,按右 Option;首次若显示「正在准备本地识别」,等变为「聆听中」再说话,再按一次结束并写入。
|
||||||
|
|
||||||
「模型已安装」不代表引擎已经就绪,菜单和语音设置会分别显示两种状态。自动模式遇到分析器加载失败时,会记住本进程的降级结果,避免每次录音重新等待失败引擎。旧版本地引擎首次使用需单独允许系统「语音识别」权限;授权期间不会显示正在录音。
|
「模型已安装」不代表引擎已经就绪,菜单和语音设置会分别显示两种状态。自动模式遇到分析器加载失败时,会记住本进程的降级结果,避免每次录音重新等待失败引擎。旧版本地引擎首次使用需单独允许系统「语音识别」权限;授权期间不会显示正在录音。
|
||||||
|
|
||||||
若没有文字,可用语音页「检查本地识别(合成语音)」区分本地引擎故障与麦克风输入问题。该检查使用固定测试文本,不采集麦克风,也不写入其他应用。
|
若没有文字,可用语音页「检查本地识别(合成语音)」检查当前选择的引擎,区分本地引擎故障与麦克风输入问题。该检查使用固定测试文本,不采集麦克风,也不写入其他应用。系统服务恢复后可点「重新准备引擎」,清除本进程的失败降级状态。
|
||||||
|
|
||||||
设置有通用、快捷键、语音、关于四个页面。语音页可在自动、本地 SpeechTranscriber、本地 DictationTranscriber 和旧版本地识别间选择。自动模式优先 SpeechTranscriber,不支持时降级;旧版强制 `requiresOnDeviceRecognition`,额外需要系统语音识别权限,缺少本地能力时不会转为云端识别。
|
设置有通用、快捷键、语音、关于四个页面。语音页可在自动、本地 SpeechTranscriber、本地 DictationTranscriber 和旧版本地识别间选择。自动模式优先 SpeechTranscriber,不支持时降级;旧版强制 `requiresOnDeviceRecognition`,额外需要系统语音识别权限,缺少本地能力时不会转为云端识别。
|
||||||
|
|
||||||
## 文字写入与隐私
|
## 文字写入与隐私
|
||||||
|
|
||||||
优先通过辅助功能替换当前选区;不支持 AX 写入时回退剪贴板和 ⌘V。剪贴板会保存所有可读取的数据类型,在没有被其他操作改动时恢复。不同应用的粘贴速度和 AX 行为可能不同;⌘V 事件发出不代表目标已经确认接收,请确认输入框结果。必要时从菜单「复制上次识别结果」找回文字。
|
默认通过 Unicode 键盘事件直接写入当前输入框并替换选区,不读写剪贴板;支持中文、英文、emoji 和长文本。设置仍提供仅 AX 和自动粘贴两种兼容方式;只有显式选择自动粘贴时才临时占用剪贴板并在未被其他操作改动时恢复。输入事件已发出不代表所有应用均已接收;必要时从菜单「复制上次识别结果」找回文字。
|
||||||
|
|
||||||
录音期间切换前台应用,或结束时进入安全输入模式,会阻止自动写入并将文字保留在剪贴板。密码等 Secure Input 场景不触发录音。取消中的会话不会回写结果。
|
录音期间切换前台应用,或结束时进入安全输入模式,会阻止自动写入并将文字保留在本进程的上次识别结果中。密码等 Secure Input 场景不触发录音。取消中的会话不会回写结果。
|
||||||
|
|
||||||
不保存音频文件、不上传录音,不记录识别原文;本次结果只在进程内保留,退出后清除。设置使用 UserDefaults。诊断日志位于 `~/Library/Logs/TinyTTS.log`,记录错误、字符数和时间,超过约 1 MB 轮换为 `.log.1`;系统崩溃报告位于 macOS 的 DiagnosticReports 目录。模型下载由 Apple 系统服务完成。
|
不保存音频文件、不上传录音,不记录识别原文;本次结果只在进程内保留,退出后清除。设置使用 UserDefaults。诊断日志位于 `~/Library/Logs/TinySTT.log`,记录错误、字符数和时间,超过约 1 MB 轮换为 `.log.1`;系统崩溃报告位于 macOS 的 DiagnosticReports 目录。模型下载由 Apple 系统服务完成。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
@@ -75,19 +76,23 @@ TINYTTS_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh
|
|||||||
./scripts/build.sh
|
./scripts/build.sh
|
||||||
|
|
||||||
# 用真实本地模型识别系统合成语音,需先安装模型
|
# 用真实本地模型识别系统合成语音,需先安装模型
|
||||||
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest
|
./dist/TinySTT.app/Contents/MacOS/TinySTT --selftest
|
||||||
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest --dictation
|
./dist/TinySTT.app/Contents/MacOS/TinySTT --selftest --dictation
|
||||||
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest --legacy
|
./dist/TinySTT.app/Contents/MacOS/TinySTT --selftest --legacy
|
||||||
|
|
||||||
|
# 真实 AppKit 输入、选区替换和剪贴板不变检查,无需语音模型
|
||||||
|
# 通过 LaunchServices 启动,以沿用 app 的系统权限归属
|
||||||
|
open -n -W --stdout /tmp/tinystt-input-test.log dist/TinySTT.app --args --selftest --input
|
||||||
```
|
```
|
||||||
|
|
||||||
自动回归覆盖文字处理、热键编码、重采样、超时以及启动/收尾取消竞态。模型自检使用实时速率喂入合成语音,输出首字和收尾时间;它不代表真人麦克风、全局热键或跨应用写入验收。真实识别与跨应用上屏仍需在系统语音服务正常时完成验收。
|
自动回归覆盖文字处理、热键编码、重采样、超时以及启动/收尾取消竞态。模型自检使用实时速率喂入合成语音,输出首字和收尾时间;它不代表真人麦克风、全局热键或跨应用写入验收。本机恢复系统 ANE 服务后,SpeechTranscriber 与 DictationTranscriber 均通过真实中文模型自检,真实麦克风测试也能返回字幕;验收详情见 [TESTING.md](TESTING.md)。
|
||||||
|
|
||||||
计划中的首字 <300 ms、结束到上屏 <500 ms、空闲 CPU <1%、内存 <80 MB 是验收目标,不是跨设备保证。首次加载模型、说话前静音、系统调度和应用粘贴处理都会影响数据。日志中的 `release to insertion dispatch` 仅测到写入请求发出。
|
计划中的首字 <300 ms、结束到上屏 <500 ms、空闲 CPU <1%、内存 <80 MB 是验收目标,不是跨设备保证。首次加载模型、说话前静音、系统调度和应用粘贴处理都会影响数据。日志中的 `release to insertion dispatch` 仅测到写入请求发出。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```
|
```
|
||||||
Sources/TinyTTS/
|
Sources/TinySTT/
|
||||||
App/ 应用生命周期与听写状态机
|
App/ 应用生命周期与听写状态机
|
||||||
Hotkey/ 全局按键与改绑
|
Hotkey/ 全局按键与改绑
|
||||||
Speech/ 模型、预热、本地识别与音频采集
|
Speech/ 模型、预热、本地识别与音频采集
|
||||||
@@ -99,7 +104,7 @@ Resources/ 图标与 Info.plist
|
|||||||
scripts/ 构建、测试、签名、图标生成
|
scripts/ 构建、测试、签名、图标生成
|
||||||
```
|
```
|
||||||
|
|
||||||
重建 logo:`swift scripts/make-icon.swift Resources`。图标使用原生 AppKit 绘图,可重复生成 16–1024 px PNG 和 ICNS,深靛蓝底上的麦克风以文字条镂空。小尺寸简化细节以保证辨识度。
|
重建图标包:`swift scripts/make-icon.swift Resources`。图标以独特的 T 字母为主体,将语音波形融入字母轮廓。源图为 `Resources/AppIcon-source.png`,生成提示词保存在 `Resources/Logo.prompt.txt`;脚本使用原生 AppKit 生成 16–1024 px PNG 和 ICNS。
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 1010 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 819 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 1010 KiB |
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>zh_CN</string>
|
<string>zh_CN</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>TinyTTS</string>
|
<string>TinySTT</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>com.eccstartup.tinystt</string>
|
<string>com.eccstartup.tinystt</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
@@ -13,15 +13,15 @@
|
|||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>TinyTTS</string>
|
<string>TinySTT</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>TinyTTS</string>
|
<string>TinySTT</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.2</string>
|
<string>0.1.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>3</string>
|
<string>4</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>26.0</string>
|
<string>26.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>TinyTTS 需要用麦克风把你说的话转成文字。</string>
|
<string>TinySTT 需要用麦克风把你说的话转成文字。</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>TinyTTS 在本机完成语音识别,音频不会上传到任何服务器。</string>
|
<string>TinySTT 在本机完成语音识别,音频不会上传到任何服务器。</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>TinyTTS</string>
|
<string>TinySTT</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Tool: built-in image_gen (transparent background)
|
||||||
|
Use case: logo-brand. Asset: final macOS application icon for TinySTT, local speech-to-text app. Design a beautiful, distinctive, unmistakable uppercase T monogram. One bold sculptural white-to-pale-mint ribbon forms a broad top bar and strong central descending stem; a subtle stepped cut at the right end of the crossbar suggests an audio waveform becoming text. Keep T extremely legible. Center this symbol inside a dark midnight-teal macOS squircle tile with a restrained rich teal glow near the upper left, soft polished depth and precise edges. Contemporary premium native Mac app aesthetic, confident and simple, not a generic microphone. Front-on flat icon asset, not a perspective mockup. Tile occupies about 82 percent of the square canvas, transparent outside the rounded tile, no white background. Large monogram, clean negative space, no delicate lines, looks excellent at 16 and 32 pixels. Only the single stylized T letter, no app name, words, border, extra icons, watermark, scenery, pedestal or decorative sparkles. 1024x1024 square.
|
||||||
@@ -39,7 +39,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
startHotkeyWithRetry()
|
startHotkeyWithRetry()
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: .tinytssHotkeyChanged,
|
forName: .tinysttHotkeyChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
@@ -52,7 +52,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(forName: .init("tinytss.recorder"), object: nil, queue: .main) { [weak self] notification in
|
NotificationCenter.default.addObserver(forName: .init("tinystt.recorder"), object: nil, queue: .main) { [weak self] notification in
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
self?.hotkey.suspended = notification.object as? Bool ?? false
|
self?.hotkey.suspended = notification.object as? Bool ?? false
|
||||||
self?.controller.cancel()
|
self?.controller.cancel()
|
||||||
@@ -135,7 +135,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private func presentError(_ message: String) {
|
private func presentError(_ message: String) {
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "TinyTTS"
|
alert.messageText = "TinySTT"
|
||||||
alert.informativeText = message
|
alert.informativeText = message
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.addButton(withTitle: "知道了")
|
alert.addButton(withTitle: "知道了")
|
||||||
@@ -83,7 +83,7 @@ final class DictationController: ObservableObject {
|
|||||||
Task { [self] in
|
Task { [self] in
|
||||||
do {
|
do {
|
||||||
guard await microphone() else {
|
guard await microphone() else {
|
||||||
throw NSError(domain: "TinyTTS", code: 1, userInfo: [NSLocalizedDescriptionKey: "请在系统设置中授权麦克风。 "])
|
throw NSError(domain: "TinySTT", code: 1, userInfo: [NSLocalizedDescriptionKey: "请在系统设置中授权麦克风。 "])
|
||||||
}
|
}
|
||||||
guard generation == token, state == .starting else { return }
|
guard generation == token, state == .starting else { return }
|
||||||
let session = try await acquire()
|
let session = try await acquire()
|
||||||
@@ -204,10 +204,10 @@ final class DictationController: ObservableObject {
|
|||||||
|
|
||||||
let outcome = Inserter.insert(text, method: prefs.insertMethod, expectedPID: targetPID)
|
let outcome = Inserter.insert(text, method: prefs.insertMethod, expectedPID: targetPID)
|
||||||
Log.info("release to insertion dispatch \(Int(Date().timeIntervalSince(t1) * 1000))ms")
|
Log.info("release to insertion dispatch \(Int(Date().timeIntervalSince(t1) * 1000))ms")
|
||||||
if prefs.playSound { NSSound(named: "Pop")?.play() }
|
if prefs.playSound, outcome.ok { NSSound(named: "Glass")?.play() }
|
||||||
Log.info("insert via \(outcome.method.rawValue) ok=\(outcome.ok), chars=\(text.count)")
|
Log.info("insert via \(outcome.method.rawValue) ok=\(outcome.ok), chars=\(text.count)")
|
||||||
if !outcome.ok {
|
if !outcome.ok {
|
||||||
self.onError?("未能确认文字写入,已复制到剪贴板;请先检查输入框,再决定是否按 ⌘V。")
|
self.onError?("未能自动写入当前输入框,识别结果已保留;可从菜单「复制上次识别结果」找回。")
|
||||||
}
|
}
|
||||||
self.state = .idle
|
self.state = .idle
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ final class HotkeyMonitor {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !suspended else { return }
|
guard !suspended, event.getIntegerValueField(.eventSourceUserData) != Inserter.syntheticEventMarker else { return }
|
||||||
if secureInput() {
|
if secureInput() {
|
||||||
if modifierDown || comboDown { fire(.cancel) }
|
if modifierDown || comboDown { fire(.cancel) }
|
||||||
modifierDown = false
|
modifierDown = false
|
||||||
@@ -16,7 +16,12 @@ enum SelfTest {
|
|||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
try await perform()
|
if CommandLine.arguments.contains("--input") {
|
||||||
|
try await performInput()
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
let choice = CommandLine.arguments.contains("--legacy") ? "legacy" : (CommandLine.arguments.contains("--dictation") ? "dictation" : "speech")
|
||||||
|
try await perform(choice: choice)
|
||||||
exit(0)
|
exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
print("SELFTEST FAILED: \(error.localizedDescription)")
|
print("SELFTEST FAILED: \(error.localizedDescription)")
|
||||||
@@ -28,18 +33,45 @@ enum SelfTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func failure(_ text: String) -> Error {
|
private static func failure(_ text: String) -> Error {
|
||||||
NSError(domain: "TinyTTS.SelfTest", code: 1, userInfo: [NSLocalizedDescriptionKey: text])
|
NSError(domain: "TinySTT.SelfTest", code: 1, userInfo: [NSLocalizedDescriptionKey: text])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exercise real AppKit text input and selection replacement, without speech
|
||||||
|
/// or clipboard mutation, in a disposable window owned by this process.
|
||||||
|
@MainActor
|
||||||
|
private static func performInput() async throws {
|
||||||
|
let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 500, height: 200),
|
||||||
|
styleMask: [.titled], backing: .buffered, defer: false)
|
||||||
|
window.title = "TinySTT 自动输入测试"
|
||||||
|
let view = NSTextView(frame: window.contentView!.bounds)
|
||||||
|
window.contentView = view
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
window.makeFirstResponder(view)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
defer { window.orderOut(nil) }
|
||||||
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
|
let clipboardVersion = NSPasteboard.general.changeCount
|
||||||
|
let phrase = String(repeating: "中文🙂English ", count: 5)
|
||||||
|
let first = Inserter.insert(phrase, method: .auto, expectedPID: getpid())
|
||||||
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard first.ok, view.string == phrase else {
|
||||||
|
throw failure("Unicode 自动输入失败:期望 \(phrase.utf16.count) 个 UTF-16 单元,收到 \(view.string.utf16.count) 个。")
|
||||||
|
}
|
||||||
|
view.string = "前旧后"
|
||||||
|
view.setSelectedRange(NSRange(location: 1, length: 1))
|
||||||
|
let second = Inserter.insert("替换🙂", method: .auto, expectedPID: getpid())
|
||||||
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard second.ok, view.string == "前替换🙂后" else { throw failure("自动输入选区替换失败。") }
|
||||||
|
guard NSPasteboard.general.changeCount == clipboardVersion else { throw failure("自动输入意外改动剪贴板。") }
|
||||||
|
print("SELFTEST PASSED (real AppKit Unicode input, selection replacement, clipboard unchanged)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func perform() async throws {
|
static func perform(choice: String) async throws {
|
||||||
if CommandLine.arguments.contains("--legacy") { try await performLegacy(); return }
|
if choice == "legacy" { try await performLegacy(); return }
|
||||||
let useDictation = CommandLine.arguments.contains("--dictation")
|
|
||||||
let locale = Locale(identifier: "zh-CN")
|
let locale = Locale(identifier: "zh-CN")
|
||||||
let expected = "今天的天气不错,我们下午三点开会。"
|
let expected = "今天的天气不错,我们下午三点开会。"
|
||||||
let module: any SpeechModule = useDictation
|
let module = try await SpeechBackend.module(locale: locale, choice: choice)
|
||||||
? DictationTranscriber(locale: locale, preset: .progressiveShortDictation)
|
|
||||||
: SpeechTranscriber(locale: locale, preset: .progressiveTranscription)
|
|
||||||
let status = await AssetInventory.status(forModules: [module])
|
let status = await AssetInventory.status(forModules: [module])
|
||||||
print("Model: \(status)")
|
print("Model: \(status)")
|
||||||
guard status == .installed else { throw failure("模型未安装;请先在设置中下载模型。") }
|
guard status == .installed else { throw failure("模型未安装;请先在设置中下载模型。") }
|
||||||
@@ -27,7 +27,7 @@ enum SpeechBackend {
|
|||||||
let supported = await DictationTranscriber.supportedLocale(equivalentTo: locale) {
|
let supported = await DictationTranscriber.supportedLocale(equivalentTo: locale) {
|
||||||
return DictationTranscriber(locale: supported, preset: .progressiveShortDictation)
|
return DictationTranscriber(locale: supported, preset: .progressiveShortDictation)
|
||||||
}
|
}
|
||||||
throw NSError(domain: "TinyTTS", code: 2, userInfo: [NSLocalizedDescriptionKey: "当前引擎不支持该语言,请选择自动或旧版本地识别。"])
|
throw NSError(domain: "TinySTT", code: 2, userInfo: [NSLocalizedDescriptionKey: "当前引擎不支持该语言,请选择自动或旧版本地识别。"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ final class LegacySession: RecognitionSession {
|
|||||||
nonisolated static func make(locale: Locale) async throws -> LegacySession {
|
nonisolated static func make(locale: Locale) async throws -> LegacySession {
|
||||||
// Do not prompt during background warm-up; request only on explicit recording.
|
// Do not prompt during background warm-up; request only on explicit recording.
|
||||||
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.supportsOnDeviceRecognition else {
|
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.supportsOnDeviceRecognition else {
|
||||||
throw NSError(domain: "TinyTTS", code: 3, userInfo: [NSLocalizedDescriptionKey: "该语言没有可用的本地语音引擎。"])
|
throw NSError(domain: "TinySTT", code: 3, userInfo: [NSLocalizedDescriptionKey: "该语言没有可用的本地语音引擎。"])
|
||||||
}
|
}
|
||||||
return await LegacySession(recognizer)
|
return await LegacySession(recognizer)
|
||||||
}
|
}
|
||||||
@@ -68,16 +68,16 @@ final class LegacySession: RecognitionSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard status == .authorized else {
|
guard status == .authorized else {
|
||||||
throw NSError(domain: "TinyTTS", code: 4, userInfo: [NSLocalizedDescriptionKey: "请在系统设置 → 隐私与安全性 → 语音识别中允许 TinyTTS。"])
|
throw NSError(domain: "TinySTT", code: 4, userInfo: [NSLocalizedDescriptionKey: "请在系统设置 → 隐私与安全性 → 语音识别中允许 TinySTT。"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() throws {
|
func start() throws {
|
||||||
guard SFSpeechRecognizer.authorizationStatus() == .authorized else {
|
guard SFSpeechRecognizer.authorizationStatus() == .authorized else {
|
||||||
throw NSError(domain: "TinyTTS", code: 4, userInfo: [NSLocalizedDescriptionKey: "旧版引擎需要语音识别权限;请授权后再次开始。"])
|
throw NSError(domain: "TinySTT", code: 4, userInfo: [NSLocalizedDescriptionKey: "旧版引擎需要语音识别权限;请授权后再次开始。"])
|
||||||
}
|
}
|
||||||
guard recognizer.isAvailable else {
|
guard recognizer.isAvailable else {
|
||||||
throw NSError(domain: "TinyTTS", code: 5, userInfo: [NSLocalizedDescriptionKey: "本地识别当前不可用,请检查系统听写模型。"])
|
throw NSError(domain: "TinySTT", code: 5, userInfo: [NSLocalizedDescriptionKey: "本地识别当前不可用,请检查系统听写模型。"])
|
||||||
}
|
}
|
||||||
recognition = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
recognition = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -92,7 +92,7 @@ final class LegacySession: RecognitionSession {
|
|||||||
}
|
}
|
||||||
let input = engine.inputNode
|
let input = engine.inputNode
|
||||||
let format = input.outputFormat(forBus: 0)
|
let format = input.outputFormat(forBus: 0)
|
||||||
guard format.sampleRate > 0 else { throw NSError(domain: "TinyTTS", code: 6, userInfo: [NSLocalizedDescriptionKey: "没有可用麦克风。 "]) }
|
guard format.sampleRate > 0 else { throw NSError(domain: "TinySTT", code: 6, userInfo: [NSLocalizedDescriptionKey: "没有可用麦克风。 "]) }
|
||||||
let request = self.request
|
let request = self.request
|
||||||
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
||||||
request.append(buffer)
|
request.append(buffer)
|
||||||
@@ -9,7 +9,7 @@ enum SpeechEngine {
|
|||||||
private static var generation = UUID()
|
private static var generation = UUID()
|
||||||
private static var preferLegacy = false
|
private static var preferLegacy = false
|
||||||
static var statusDescription = "引擎未就绪" {
|
static var statusDescription = "引擎未就绪" {
|
||||||
didSet { NotificationCenter.default.post(name: .init("tinytss.engineStatus"), object: nil) }
|
didSet { NotificationCenter.default.post(name: .init("tinystt.engineStatus"), object: nil) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static var currentLocale: Locale { Locale(identifier: Prefs.shared.languageCode) }
|
static var currentLocale: Locale { Locale(identifier: Prefs.shared.languageCode) }
|
||||||
@@ -67,7 +67,7 @@ enum SpeechEngine {
|
|||||||
catch { return .failure(error) }
|
catch { return .failure(error) }
|
||||||
}
|
}
|
||||||
guard let result else {
|
guard let result else {
|
||||||
throw NSError(domain: "TinyTTS", code: 7, userInfo: [NSLocalizedDescriptionKey: "本地分析器加载超时。"])
|
throw NSError(domain: "TinySTT", code: 7, userInfo: [NSLocalizedDescriptionKey: "本地分析器加载超时。"])
|
||||||
}
|
}
|
||||||
return try result.get()
|
return try result.get()
|
||||||
}
|
}
|
||||||
@@ -18,9 +18,7 @@ final class SpeechSession: RecognitionSession {
|
|||||||
private var tapInstalled = false
|
private var tapInstalled = false
|
||||||
private var isFinished = false
|
private var isFinished = false
|
||||||
|
|
||||||
private var segments: [Int: String] = [:]
|
private var transcript = TranscriptAccumulator()
|
||||||
private var volatileKey: Int?
|
|
||||||
private var volatileText = ""
|
|
||||||
|
|
||||||
var onTextChanged: ((String) -> Void)?
|
var onTextChanged: ((String) -> Void)?
|
||||||
var onAudioLevel: ((Float) -> Void)?
|
var onAudioLevel: ((Float) -> Void)?
|
||||||
@@ -57,7 +55,7 @@ final class SpeechSession: RecognitionSession {
|
|||||||
}
|
}
|
||||||
guard let prepared else {
|
guard let prepared else {
|
||||||
Task { await analyzer.cancelAndFinishNow() }
|
Task { await analyzer.cancelAndFinishNow() }
|
||||||
throw NSError(domain: "TinyTTS", code: 7, userInfo: [NSLocalizedDescriptionKey: "本地分析器加载超时,正在尝试备用引擎。"])
|
throw NSError(domain: "TinySTT", code: 7, userInfo: [NSLocalizedDescriptionKey: "本地分析器加载超时,正在尝试备用引擎。"])
|
||||||
}
|
}
|
||||||
try prepared.get()
|
try prepared.get()
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
@@ -78,8 +76,7 @@ final class SpeechSession: RecognitionSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentText: String {
|
var currentText: String {
|
||||||
let ordered = segments.keys.sorted().compactMap { segments[$0] }
|
transcript.text
|
||||||
return ordered.joined() + volatileText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() throws {
|
func start() throws {
|
||||||
@@ -91,11 +88,11 @@ final class SpeechSession: RecognitionSession {
|
|||||||
do {
|
do {
|
||||||
if let module = self.transcriber as? SpeechTranscriber {
|
if let module = self.transcriber as? SpeechTranscriber {
|
||||||
for try await result in module.results {
|
for try await result in module.results {
|
||||||
self.apply(key: Int((result.range.start.seconds * 1000).rounded()), text: String(result.text.characters), final: result.isFinal)
|
self.apply(range: result.range, text: String(result.text.characters), final: result.isFinal)
|
||||||
}
|
}
|
||||||
} else if let module = self.transcriber as? DictationTranscriber {
|
} else if let module = self.transcriber as? DictationTranscriber {
|
||||||
for try await result in module.results {
|
for try await result in module.results {
|
||||||
self.apply(key: Int((result.range.start.seconds * 1000).rounded()), text: String(result.text.characters), final: result.isFinal)
|
self.apply(range: result.range, text: String(result.text.characters), final: result.isFinal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -150,12 +147,12 @@ final class SpeechSession: RecognitionSession {
|
|||||||
let input = engine.inputNode
|
let input = engine.inputNode
|
||||||
let hwFormat = input.inputFormat(forBus: 0)
|
let hwFormat = input.inputFormat(forBus: 0)
|
||||||
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
|
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
|
||||||
throw NSError(domain: "TinyTTS", code: -10,
|
throw NSError(domain: "TinySTT", code: -10,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "没有可用的麦克风输入"])
|
userInfo: [NSLocalizedDescriptionKey: "没有可用的麦克风输入"])
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let converter = AVAudioConverter(from: hwFormat, to: analyzerFormat) else {
|
guard let converter = AVAudioConverter(from: hwFormat, to: analyzerFormat) else {
|
||||||
throw NSError(domain: "TinyTTS", code: -11,
|
throw NSError(domain: "TinySTT", code: -11,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "无法创建音频格式转换器"])
|
userInfo: [NSLocalizedDescriptionKey: "无法创建音频格式转换器"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,23 +189,12 @@ final class SpeechSession: RecognitionSession {
|
|||||||
|
|
||||||
// MARK: - 结果
|
// MARK: - 结果
|
||||||
|
|
||||||
private func apply(key: Int, text: String, final: Bool) {
|
private func apply(range: CMTimeRange, text: String, final: Bool) {
|
||||||
if final {
|
transcript.apply(start: range.start.seconds, end: CMTimeRangeGetEnd(range).seconds,
|
||||||
if text.isEmpty {
|
text: text, final: final)
|
||||||
segments.removeValue(forKey: key)
|
|
||||||
} else {
|
|
||||||
segments[key] = text
|
|
||||||
}
|
|
||||||
if volatileKey == key {
|
|
||||||
volatileKey = nil
|
|
||||||
volatileText = ""
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
volatileKey = key
|
|
||||||
volatileText = text
|
|
||||||
}
|
|
||||||
onTextChanged?(currentText)
|
onTextChanged?(currentText)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 工具
|
// MARK: - 工具
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Speech results may revise their time boundaries when they become final.
|
||||||
|
/// Match coverage rather than an exact rounded start timestamp.
|
||||||
|
struct TranscriptAccumulator {
|
||||||
|
private struct Segment {
|
||||||
|
var start: Double
|
||||||
|
var end: Double
|
||||||
|
var text: String
|
||||||
|
}
|
||||||
|
private var finalized: [Segment] = []
|
||||||
|
private var volatile: [Segment] = []
|
||||||
|
private let tolerance = 0.001
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
(finalized + volatile).sorted { $0.start < $1.start }.map(\.text).joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func apply(start: Double, end: Double, text: String, final: Bool) {
|
||||||
|
guard start.isFinite, end.isFinite, end >= start else { return }
|
||||||
|
let segment = Segment(start: start, end: end, text: text)
|
||||||
|
func overlaps(_ other: Segment) -> Bool {
|
||||||
|
abs(other.start - start) <= tolerance ||
|
||||||
|
(other.start < end - tolerance && other.end > start + tolerance)
|
||||||
|
}
|
||||||
|
if final {
|
||||||
|
finalized.removeAll(where: overlaps)
|
||||||
|
// A final result supersedes provisional coverage up to its end,
|
||||||
|
// including a slightly shifted start boundary.
|
||||||
|
volatile.removeAll { $0.end <= end + tolerance || overlaps($0) }
|
||||||
|
if !text.isEmpty { finalized.append(segment) }
|
||||||
|
} else {
|
||||||
|
volatile.removeAll(where: overlaps)
|
||||||
|
// Late provisional callbacks must never duplicate finalized audio.
|
||||||
|
guard !finalized.contains(where: overlaps) else { return }
|
||||||
|
if !text.isEmpty { volatile.append(segment) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 轻量日志:写到 ~/Library/Logs/TinyTTS.log,方便排查(也便于无 UI 时验证)。
|
/// 轻量日志:写到 ~/Library/Logs/TinySTT.log,方便排查(也便于无 UI 时验证)。
|
||||||
enum Log {
|
enum Log {
|
||||||
private static let queue = DispatchQueue(label: "tinytss.log")
|
private static let queue = DispatchQueue(label: "tinystt.log")
|
||||||
private static let url: URL = {
|
private static let url: URL = {
|
||||||
let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||||
.appendingPathComponent("Logs", isDirectory: true)
|
.appendingPathComponent("Logs", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir.appendingPathComponent("TinyTTS.log")
|
return dir.appendingPathComponent("TinySTT.log")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let formatter: DateFormatter = {
|
private static let formatter: DateFormatter = {
|
||||||
@@ -22,7 +22,7 @@ enum InsertMethod: String, CaseIterable, Identifiable {
|
|||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .auto: "自动(AX 失败则粘贴)"
|
case .auto: "直接输入(不使用剪贴板)"
|
||||||
case .axOnly: "仅 AX 直接写入"
|
case .axOnly: "仅 AX 直接写入"
|
||||||
case .paste: "始终使用粘贴"
|
case .paste: "始终使用粘贴"
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ final class Prefs: ObservableObject {
|
|||||||
if let data = try? JSONEncoder().encode(hotkey) {
|
if let data = try? JSONEncoder().encode(hotkey) {
|
||||||
defaults.set(data, forKey: Keys.hotkey)
|
defaults.set(data, forKey: Keys.hotkey)
|
||||||
}
|
}
|
||||||
NotificationCenter.default.post(name: .tinytssHotkeyChanged, object: nil)
|
NotificationCenter.default.post(name: .tinysttHotkeyChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +115,6 @@ final class Prefs: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let tinytssHotkeyChanged = Notification.Name("tinytss.hotkeyChanged")
|
static let tinysttHotkeyChanged = Notification.Name("tinystt.hotkeyChanged")
|
||||||
static let tinytssOpenSettings = Notification.Name("tinytss.openSettings")
|
static let tinysttOpenSettings = Notification.Name("tinystt.openSettings")
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,13 @@ import CoreGraphics
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 把文字写进当前焦点输入框。
|
/// 把文字写进当前焦点输入框。
|
||||||
/// 策略:AX 直接写入(可验证)→ 剪贴板 + ⌘V → 失败则留在剪贴板。
|
/// 默认通过 Unicode 键盘事件直接输入,不占用剪贴板。
|
||||||
enum Inserter {
|
enum Inserter {
|
||||||
enum Method: String {
|
enum Method: String {
|
||||||
case accessibility = "AX"
|
case accessibility = "AX"
|
||||||
case paste = "⌘V"
|
case paste = "⌘V"
|
||||||
case clipboard = "剪贴板"
|
case keyboard = "直接输入"
|
||||||
|
case retained = "保留结果"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Outcome {
|
struct Outcome {
|
||||||
@@ -20,29 +21,18 @@ enum Inserter {
|
|||||||
guard !text.isEmpty else { return Outcome(method: .accessibility, ok: false) }
|
guard !text.isEmpty else { return Outcome(method: .accessibility, ok: false) }
|
||||||
|
|
||||||
if isSecureEventInputEnabled() || (expectedPID != nil && NSWorkspace.shared.frontmostApplication?.processIdentifier != expectedPID) {
|
if isSecureEventInputEnabled() || (expectedPID != nil && NSWorkspace.shared.frontmostApplication?.processIdentifier != expectedPID) {
|
||||||
// 密码框禁止注入,直接放剪贴板更安全
|
return Outcome(method: .retained, ok: false)
|
||||||
copyToPasteboard(text)
|
|
||||||
return Outcome(method: .clipboard, ok: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch method {
|
switch method {
|
||||||
case .axOnly:
|
case .axOnly:
|
||||||
let result = insertViaAX(text)
|
let result = insertViaAX(text)
|
||||||
if result != .inserted { copyToPasteboard(text) }
|
|
||||||
return Outcome(method: .accessibility, ok: result == .inserted)
|
return Outcome(method: .accessibility, ok: result == .inserted)
|
||||||
case .paste:
|
case .paste:
|
||||||
paste(text)
|
paste(text)
|
||||||
return Outcome(method: .paste, ok: true)
|
return Outcome(method: .paste, ok: true)
|
||||||
case .auto:
|
case .auto:
|
||||||
switch insertViaAX(text) {
|
return Outcome(method: .keyboard, ok: typeText(text, expectedPID: expectedPID))
|
||||||
case .inserted: return Outcome(method: .accessibility, ok: true)
|
|
||||||
case .uncertain:
|
|
||||||
copyToPasteboard(text)
|
|
||||||
return Outcome(method: .clipboard, ok: false)
|
|
||||||
case .unavailable:
|
|
||||||
paste(text)
|
|
||||||
return Outcome(method: .paste, ok: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +110,52 @@ enum Inserter {
|
|||||||
return CGRect(origin: point, size: size)
|
return CGRect(origin: point, size: size)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 剪贴板
|
// MARK: - 直接输入
|
||||||
|
|
||||||
private static func copyToPasteboard(_ text: String) {
|
static let syntheticEventMarker: Int64 = 0x54494E59545453
|
||||||
let pb = NSPasteboard.general
|
|
||||||
pb.clearContents()
|
/// Keep each Quartz event small without splitting a UTF-16 surrogate pair.
|
||||||
pb.setString(text, forType: .string)
|
static func unicodeChunks(_ text: String) -> [[UInt16]] {
|
||||||
|
var chunks: [[UInt16]] = []
|
||||||
|
var chunk: [UInt16] = []
|
||||||
|
for scalar in text.unicodeScalars {
|
||||||
|
let units = Array(String(scalar).utf16)
|
||||||
|
if chunk.count + units.count > 20 {
|
||||||
|
chunks.append(chunk)
|
||||||
|
chunk = []
|
||||||
|
}
|
||||||
|
chunk.append(contentsOf: units)
|
||||||
|
}
|
||||||
|
if !chunk.isEmpty { chunks.append(chunk) }
|
||||||
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func typeText(_ text: String, expectedPID: pid_t?) -> Bool {
|
||||||
|
guard AXIsProcessTrusted(),
|
||||||
|
let target = NSWorkspace.shared.frontmostApplication?.processIdentifier,
|
||||||
|
expectedPID == nil || target == expectedPID,
|
||||||
|
let source = CGEventSource(stateID: .privateState) else { return false }
|
||||||
|
// Do not inherit a still-held Option key from the end-of-dictation hotkey.
|
||||||
|
// Posting to the captured app also avoids rerouting queued input elsewhere.
|
||||||
|
for chunk in unicodeChunks(text) {
|
||||||
|
guard !isSecureEventInputEnabled(),
|
||||||
|
NSWorkspace.shared.frontmostApplication?.processIdentifier == target,
|
||||||
|
let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true),
|
||||||
|
let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) else { return false }
|
||||||
|
for event in [down, up] {
|
||||||
|
event.flags = []
|
||||||
|
event.setIntegerValueField(.eventSourceUserData, value: syntheticEventMarker)
|
||||||
|
chunk.withUnsafeBufferPointer { units in
|
||||||
|
event.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units.baseAddress)
|
||||||
|
}
|
||||||
|
event.postToPid(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 剪贴板
|
||||||
|
|
||||||
private static func paste(_ text: String) {
|
private static func paste(_ text: String) {
|
||||||
let pb = NSPasteboard.general
|
let pb = NSPasteboard.general
|
||||||
let targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
let targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||||
@@ -12,7 +12,7 @@ final class HotkeyRecorderModel: ObservableObject {
|
|||||||
func begin() {
|
func begin() {
|
||||||
guard !isRecording else { return }
|
guard !isRecording else { return }
|
||||||
isRecording = true
|
isRecording = true
|
||||||
NotificationCenter.default.post(name: .init("tinytss.recorder"), object: true)
|
NotificationCenter.default.post(name: .init("tinystt.recorder"), object: true)
|
||||||
pendingModifier = nil
|
pendingModifier = nil
|
||||||
monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
|
monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
|
||||||
guard let self else { return event }
|
guard let self else { return event }
|
||||||
@@ -28,7 +28,7 @@ final class HotkeyRecorderModel: ObservableObject {
|
|||||||
monitor = nil
|
monitor = nil
|
||||||
pendingModifier = nil
|
pendingModifier = nil
|
||||||
isRecording = false
|
isRecording = false
|
||||||
NotificationCenter.default.post(name: .init("tinytss.recorder"), object: false)
|
NotificationCenter.default.post(name: .init("tinystt.recorder"), object: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ event: NSEvent) {
|
private func handle(_ event: NSEvent) {
|
||||||
@@ -24,7 +24,7 @@ struct SettingsView: View {
|
|||||||
.tag(Tab.about)
|
.tag(Tab.about)
|
||||||
}
|
}
|
||||||
.frame(width: 540, height: 430)
|
.frame(width: 540, height: 430)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .tinytssOpenSettings)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .tinysttOpenSettings)) { _ in
|
||||||
// 保持当前 tab
|
// 保持当前 tab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ struct GeneralPane: View {
|
|||||||
Text(method.title).tag(method)
|
Text(method.title).tag(method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("「自动」会先尝试直接写入光标位置;不支持的应用(如部分网页输入框)自动改用粘贴。")
|
Text("默认通过键盘事件直接输入文字,不使用剪贴板;也可选择 AX 或自动粘贴。")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
@@ -174,7 +174,7 @@ struct SpeechPane: View {
|
|||||||
Text("DictationTranscriber").tag("dictation")
|
Text("DictationTranscriber").tag("dictation")
|
||||||
Text("旧版本地识别").tag("legacy")
|
Text("旧版本地识别").tag("legacy")
|
||||||
}
|
}
|
||||||
.disabled(dictation.state.isBusy || isDownloading)
|
.disabled(dictation.state.isBusy || isDownloading || checkingEngine)
|
||||||
.onChange(of: prefs.engineChoice) { _, _ in
|
.onChange(of: prefs.engineChoice) { _, _ in
|
||||||
SpeechEngine.resetWarm()
|
SpeechEngine.resetWarm()
|
||||||
Task { await models.refresh(); SpeechEngine.warmUp() }
|
Task { await models.refresh(); SpeechEngine.warmUp() }
|
||||||
@@ -186,6 +186,11 @@ struct SpeechPane: View {
|
|||||||
.foregroundStyle(models.state.isReady ? Color.secondary : Color.orange)
|
.foregroundStyle(models.state.isReady ? Color.secondary : Color.orange)
|
||||||
}
|
}
|
||||||
LabeledContent("引擎状态", value: engineStatus)
|
LabeledContent("引擎状态", value: engineStatus)
|
||||||
|
Button("重新准备引擎") {
|
||||||
|
SpeechEngine.resetWarm()
|
||||||
|
SpeechEngine.warmUp()
|
||||||
|
}
|
||||||
|
.disabled(dictation.state.isBusy || checkingEngine || isDownloading)
|
||||||
if case .downloading(let progress) = models.state {
|
if case .downloading(let progress) = models.state {
|
||||||
ProgressView(value: progress)
|
ProgressView(value: progress)
|
||||||
}
|
}
|
||||||
@@ -214,7 +219,7 @@ struct SpeechPane: View {
|
|||||||
dictation.start()
|
dictation.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(dictation.state == .starting || dictation.state == .finalizing || (dictation.state.isBusy && !dictation.testMode))
|
.disabled(checkingEngine || dictation.state == .starting || dictation.state == .finalizing || (dictation.state.isBusy && !dictation.testMode))
|
||||||
Text("测试不会写入任何输入框。")
|
Text("测试不会写入任何输入框。")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -241,7 +246,7 @@ struct SpeechPane: View {
|
|||||||
diagnosticResult = ""
|
diagnosticResult = ""
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await SelfTest.performLegacy()
|
try await SelfTest.perform(choice: prefs.engineChoice)
|
||||||
diagnosticResult = "本地识别通过,若麦克风测试无文字,请检查输入设备。"
|
diagnosticResult = "本地识别通过,若麦克风测试无文字,请检查输入设备。"
|
||||||
} catch {
|
} catch {
|
||||||
diagnosticResult = error.localizedDescription
|
diagnosticResult = error.localizedDescription
|
||||||
@@ -263,7 +268,7 @@ struct SpeechPane: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .init("tinytss.engineStatus"))) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .init("tinystt.engineStatus"))) { _ in
|
||||||
engineStatus = SpeechEngine.statusDescription
|
engineStatus = SpeechEngine.statusDescription
|
||||||
}
|
}
|
||||||
.onChange(of: dictation.state) { _, newValue in
|
.onChange(of: dictation.state) { _, newValue in
|
||||||
@@ -301,7 +306,7 @@ struct AboutPane: View {
|
|||||||
Image(nsImage: NSApp.applicationIconImage)
|
Image(nsImage: NSApp.applicationIconImage)
|
||||||
.resizable().frame(width: 48, height: 48)
|
.resizable().frame(width: 48, height: 48)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("TinyTTS").font(.headline)
|
Text("TinySTT").font(.headline)
|
||||||
Text("轻量级语音输入 · 全部在本机识别")
|
Text("轻量级语音输入 · 全部在本机识别")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -19,7 +19,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
window.title = "TinyTTS 设置"
|
window.title = "TinySTT 设置"
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.contentView = NSHostingView(rootView: SettingsView())
|
window.contentView = NSHostingView(rootView: SettingsView())
|
||||||
window.center()
|
window.center()
|
||||||
@@ -40,7 +40,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
|
|||||||
("设置与测试语音…", ",", #selector(settingsTapped)),
|
("设置与测试语音…", ",", #selector(settingsTapped)),
|
||||||
("下载 / 检查语音模型", "", #selector(downloadTapped)),
|
("下载 / 检查语音模型", "", #selector(downloadTapped)),
|
||||||
("复制上次识别结果", "", #selector(copyTapped)),
|
("复制上次识别结果", "", #selector(copyTapped)),
|
||||||
("退出 TinyTTS", "q", #selector(quitTapped))
|
("退出 TinySTT", "q", #selector(quitTapped))
|
||||||
] {
|
] {
|
||||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: code)
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: code)
|
||||||
item.target = self
|
item.target = self
|
||||||
@@ -53,7 +53,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
|
|||||||
Prefs.shared.objectWillChange.sink { [weak self] _ in
|
Prefs.shared.objectWillChange.sink { [weak self] _ in
|
||||||
DispatchQueue.main.async { self?.refresh() }
|
DispatchQueue.main.async { self?.refresh() }
|
||||||
}.store(in: &subscriptions)
|
}.store(in: &subscriptions)
|
||||||
NotificationCenter.default.publisher(for: .init("tinytss.engineStatus")).sink { [weak self] _ in
|
NotificationCenter.default.publisher(for: .init("tinystt.engineStatus")).sink { [weak self] _ in
|
||||||
self?.refresh()
|
self?.refresh()
|
||||||
}.store(in: &subscriptions)
|
}.store(in: &subscriptions)
|
||||||
update(state: .idle)
|
update(state: .idle)
|
||||||
@@ -80,10 +80,10 @@ final class StatusItemController: NSObject, NSMenuDelegate {
|
|||||||
private func refresh() {
|
private func refresh() {
|
||||||
let prefs = Prefs.shared
|
let prefs = Prefs.shared
|
||||||
switch state {
|
switch state {
|
||||||
case .idle: stateItem.title = "TinyTTS · 待机"
|
case .idle: stateItem.title = "TinySTT · 待机"
|
||||||
case .starting: stateItem.title = "TinyTTS · 正在准备…"
|
case .starting: stateItem.title = "TinySTT · 正在准备…"
|
||||||
case .recording: stateItem.title = "TinyTTS · 正在聆听"
|
case .recording: stateItem.title = "TinySTT · 正在聆听"
|
||||||
case .finalizing: stateItem.title = "TinyTTS · 正在整理文字…"
|
case .finalizing: stateItem.title = "TinySTT · 正在整理文字…"
|
||||||
}
|
}
|
||||||
modelItem.title = "\(prefs.languageCode) · \(ModelManager.shared.statusText)"
|
modelItem.title = "\(prefs.languageCode) · \(ModelManager.shared.statusText)"
|
||||||
engineItem.title = SpeechEngine.statusDescription
|
engineItem.title = SpeechEngine.statusDescription
|
||||||
@@ -103,7 +103,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
|
|||||||
image?.isTemplate = true
|
image?.isTemplate = true
|
||||||
button.image = image
|
button.image = image
|
||||||
button.imagePosition = .imageLeading
|
button.imagePosition = .imageLeading
|
||||||
button.title = Prefs.shared.compactIcon ? "" : " TinyTTS"
|
button.title = Prefs.shared.compactIcon ? "" : " TinySTT"
|
||||||
button.contentTintColor = state == .recording ? .systemRed : nil
|
button.contentTintColor = state == .recording ? .systemRed : nil
|
||||||
button.alphaValue = state == .recording ? 0.65 + 0.35 * sin(Double(frame) * 0.5) : 1
|
button.alphaValue = state == .recording ? 0.65 + 0.35 * sin(Double(frame) * 0.5) : 1
|
||||||
button.wantsLayer = true
|
button.wantsLayer = true
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 已通过
|
## 已通过
|
||||||
|
|
||||||
- SwiftPM debug 编译及 Swift Testing:14 项测试通过,0 失败。
|
- SwiftPM debug 编译及 Swift Testing:17 项测试通过,0 失败。
|
||||||
- Release `.app` 构建,真实 Apple Development 身份签名,沙箱外 `codesign --verify --strict` 通过。
|
- Release `.app` 构建,真实 Apple Development 身份签名,沙箱外 `codesign --verify --strict` 通过。
|
||||||
- Info.plist 格式、构建/签名/测试脚本语法检查通过。
|
- Info.plist 格式、构建/签名/测试脚本语法检查通过。
|
||||||
- 图标脚本生成 10 个尺寸 PNG、1024 px logo 和 ICNS;大图人工查看,16/32 px 简化细节。图标已包含在 app 中。
|
- 图标脚本生成 10 个尺寸 PNG、1024 px logo 和 ICNS;大图人工查看,16/32 px 简化细节。图标已包含在 app 中。
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
其中状态机回归使用可控会话替身,不代表真实麦克风和跨应用兼容性。
|
其中状态机回归使用可控会话替身,不代表真实麦克风和跨应用兼容性。
|
||||||
|
|
||||||
## 真实模型测试:未通过
|
## 早期真实模型测试:失败记录(后续恢复见下)
|
||||||
|
|
||||||
分别运行已签名 app 的 `--selftest` 与 `--selftest --dictation`。两者均确认中文系统模型为 `installed`;备用测试已完成系统合成语音与文件读取。两条路径都在 SpeechAnalyzer 准备阶段被系统语音服务拒绝:
|
分别运行已签名 app 的 `--selftest` 与 `--selftest --dictation`。两者均确认中文系统模型为 `installed`;备用测试已完成系统合成语音与文件读取。两条路径都在 SpeechAnalyzer 准备阶段被系统语音服务拒绝:
|
||||||
|
|
||||||
@@ -78,3 +78,50 @@ codesign --verify --strict dist/TinyTTS.app
|
|||||||
- 测试模式现在会对空结果显示错误;取消权限等待后不再继续获取识别会话。
|
- 测试模式现在会对空结果显示错误;取消权限等待后不再继续获取识别会话。
|
||||||
|
|
||||||
本次重新运行 `--selftest` 仍以非零状态失败,原始错误为 `Espresso V2 failure: ANE model load failure: loadModel:sandboxExtension:options:qos:withReply:: timed out`,发生在已安装模型的准备阶段;未采集麦克风。
|
本次重新运行 `--selftest` 仍以非零状态失败,原始错误为 `Espresso V2 failure: ANE model load failure: loadModel:sandboxExtension:options:qos:withReply:: timed out`,发生在已安装模型的准备阶段;未采集麦克风。
|
||||||
|
|
||||||
|
## ANE 恢复与字幕合并修复
|
||||||
|
|
||||||
|
先定向重启当前用户的语音识别服务,主模型仍返回同一 ANE 模型加载超时。经用户选择原生管理员授权后,使用 macOS 授权对话框尝试 `launchctl kickstart`,被 SIP 拒绝;随后通过正常 SIGTERM 终止已确认的 `aned` 服务,由系统重新拉起,未关闭或修改 SIP。ANE PID 从 588 变为 42024。
|
||||||
|
|
||||||
|
恢复后真实模型自检通过:
|
||||||
|
|
||||||
|
| 引擎 | 识别关键词 | 音频开始至首字 | 音频结束至最终结果 |
|
||||||
|
|---|---|---:|---:|
|
||||||
|
| SpeechTranscriber | 天气、开会 | 1230 ms | 157 ms |
|
||||||
|
| DictationTranscriber | 天气、开会 | 581 ms | 89 ms |
|
||||||
|
|
||||||
|
这些是固定语句实时喂入的单次结果,包含语音起始内容与模型调度,不代表麦克风首字延迟或跨应用上屏。
|
||||||
|
|
||||||
|
GUI 真实麦克风通过扬声器播放固定测试语句:实时字幕返回完整测试句,收尾成功。首次实录发现最终结果的时间起点与临时结果不完全一致,按起点精确匹配使整句重复。现改用时间覆盖范围合并,补充起点偏移、过时临时结果、连续多句的回归测试;修复后再次实录,收尾前后文字一致,不再重复。该次启动采集总耗时 241 ms,收尾 108 ms。
|
||||||
|
|
||||||
|
语音页增加「重新准备引擎」,系统服务恢复后无需退出即可清除降级状态。合成语音诊断改为检查当前所选引擎,诊断期间禁止同时开始麦克风测试或更改引擎。
|
||||||
|
|
||||||
|
最新自动回归:17 项通过,0 失败。Release 固定身份签名与严格校验通过。主应用包约 1.44 MiB。一次实录后关闭设置的快照为 RSS 106464 KiB、CPU 4.1%;它不是长期空闲采样,不能认定 <80 MB / <1% 目标已通过。
|
||||||
|
|
||||||
|
## 默认直接输入修复(0.1.3)
|
||||||
|
|
||||||
|
物理右 Option 已触发录音与结束,得到非空结果;旧版 AX 返回成功但回读不一致,只留下剪贴板结果,不满足自动输入要求。
|
||||||
|
|
||||||
|
默认方式现改为 Unicode 键盘事件,直接发送到开始时的目标进程,清除仍按住的修饰键状态,不读写剪贴板。事件分块不拆开 UTF-16 代理对,并标记自发事件以免触发自定义热键。前台进程改变或安全输入仍阻止注入,结果仅保留在进程内供用户找回。显式选择「始终使用粘贴」才走剪贴板兼容路径。
|
||||||
|
|
||||||
|
新增 `--selftest --input`,由正常 LaunchServices 启动已签名 app,创建真实 AppKit 文本输入窗口,自动验证中文/英文/emoji 长文本实际输入、选区替换以及剪贴板版本号保持不变。实测输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
SELFTEST PASSED (real AppKit Unicode input, selection replacement, clipboard unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
Swift Testing 当前 17 项通过;0.1.3 Release 固定身份签名与严格校验通过。外部应用的物理热键听写验收继续以 UI 实际内容与日志为准,不能只看事件发出。
|
||||||
|
|
||||||
|
## 物理热键与实际输入反馈
|
||||||
|
|
||||||
|
用户在本聊天输入框实测右 Option 开始、说话、再次按键结束,并反馈直接输入已通过。同期日志记录多次 `SpeechSession` 采集及 `insert via 直接输入 ok=true`,结束到输入事件发出约 64–81 ms。该用户反馈与真实 AppKit 输入自检共同确认当前主链可用;TextEdit 文稿并未收到这次用户测试内容,不把该次测试写成 TextEdit 兼容性通过。Chrome 与终端的独立验收仍未完成。
|
||||||
|
|
||||||
|
按用户要求,完成输入提示音改为系统 Glass「叮」声,仅在输入派发成功时播放。开始录音的提示保持原设置。
|
||||||
|
|
||||||
|
## 应用更名
|
||||||
|
|
||||||
|
按用户纠正,将当前工程、Swift 模块、测试模块、界面及产物统一改为 TinySTT / `dist/TinySTT.app`。保留 `com.eccstartup.tinystt` bundle ID 与既有偏好键以保持权限和设置关联。新的签名覆盖变量为 `TINYSTT_SIGN_IDENTITY`,仍兼容旧变量和已安装的 TinyTTS Dev 证书。旧验收段落中的 TinyTTS 路径为历史记录;当前复现请使用 README 中的 TinySTT 命令。
|
||||||
|
|
||||||
|
新版 T 字母图标使用内置 image_gen 生成,保存项目内源图和完整提示词,再以 AppKit 脚本生成 16–1024 px PNG 及 ICNS;不依赖外部临时图片路径。更名后的 TinySTT.app 再次通过真实 AppKit 直接输入、选区替换与剪贴板未变自检。
|
||||||
|
|
||||||
|
最终 TinySTT.app(0.1.3)包体 2840 KiB(约 2.77 MiB),固定身份签名与严格校验通过;包内 ICNS 与项目内图标 SHA-256 完全一致。16/32 px 图标已查看,T 字轮廓可辨。旧名称的构建产物已移除,仅保留 TinySTT.app。
|
||||||
@@ -2,7 +2,7 @@ import Testing
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
@testable import TinyTTS
|
@testable import TinySTT
|
||||||
|
|
||||||
struct TextTests {
|
struct TextTests {
|
||||||
@Test func punctuationAndWhitespace() {
|
@Test func punctuationAndWhitespace() {
|
||||||
@@ -245,3 +245,36 @@ struct HotkeyEventTests {
|
|||||||
#expect(released == 1)
|
#expect(released == 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TranscriptTests {
|
||||||
|
@Test func finalWithShiftedStartReplacesProvisionalText() {
|
||||||
|
var transcript = TranscriptAccumulator()
|
||||||
|
transcript.apply(start: 0.011, end: 3, text: "今天开会", final: false)
|
||||||
|
transcript.apply(start: 0, end: 3.1, text: "今天开会。", final: true)
|
||||||
|
#expect(transcript.text == "今天开会。")
|
||||||
|
transcript.apply(start: 0.011, end: 3, text: "今天开会", final: false)
|
||||||
|
#expect(transcript.text == "今天开会。")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func separateSegmentsAndRevisionsRemainInOrder() {
|
||||||
|
var transcript = TranscriptAccumulator()
|
||||||
|
transcript.apply(start: 0, end: 1, text: "第一句。", final: true)
|
||||||
|
transcript.apply(start: 1, end: 2, text: "第二", final: false)
|
||||||
|
transcript.apply(start: 2, end: 3, text: "第三", final: false)
|
||||||
|
transcript.apply(start: 1.01, end: 2, text: "第二句。", final: true)
|
||||||
|
#expect(transcript.text == "第一句。第二句。第三")
|
||||||
|
transcript.apply(start: 2, end: 3, text: "第三句。", final: true)
|
||||||
|
#expect(transcript.text == "第一句。第二句。第三句。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnicodeInputTests {
|
||||||
|
@Test func chunksPreserveChineseEmojiAndLongInput() {
|
||||||
|
let text = String(repeating: "中文🙂é", count: 30) + "结束。"
|
||||||
|
let chunks = Inserter.unicodeChunks(text)
|
||||||
|
#expect(chunks.allSatisfy { !$0.isEmpty && $0.count <= 20 })
|
||||||
|
#expect(chunks.flatMap { $0 } == Array(text.utf16))
|
||||||
|
#expect(chunks.map { String(decoding: $0, as: UTF16.self) }.joined() == text)
|
||||||
|
#expect(Inserter.unicodeChunks("").isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 构建 TinyTTS.app:swift build → 组装 bundle → 签名
|
# 构建 TinySTT.app:swift build → 组装 bundle → 签名
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@@ -11,21 +11,21 @@ CONFIG="${1:-release}"
|
|||||||
echo "==> 编译 ${CONFIG}"
|
echo "==> 编译 ${CONFIG}"
|
||||||
swift build -c "${CONFIG}" --disable-sandbox
|
swift build -c "${CONFIG}" --disable-sandbox
|
||||||
|
|
||||||
BIN="$ROOT/.build/${CONFIG}/TinyTTS"
|
BIN="$ROOT/.build/${CONFIG}/TinySTT"
|
||||||
APP="$ROOT/dist/TinyTTS.app"
|
APP="$ROOT/dist/TinySTT.app"
|
||||||
|
|
||||||
echo "==> 组装 bundle"
|
echo "==> 组装 bundle"
|
||||||
rm -rf "$APP"
|
rm -rf "$APP"
|
||||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||||
cp "$BIN" "$APP/Contents/MacOS/TinyTTS"
|
cp "$BIN" "$APP/Contents/MacOS/TinySTT"
|
||||||
cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist"
|
cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist"
|
||||||
cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns"
|
cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns"
|
||||||
|
|
||||||
# 优先使用显式指定 > Apple Development > 本地自签名 > 临时签名。
|
# 优先使用显式指定 > Apple Development > 本地自签名 > 临时签名。
|
||||||
# 固定身份很关键:否则每次重建都会让「辅助功能」授权失效。
|
# 固定身份很关键:否则每次重建都会让「辅助功能」授权失效。
|
||||||
resolve_identity() {
|
resolve_identity() {
|
||||||
if [ -n "${TINYTTS_SIGN_IDENTITY:-}" ]; then
|
if [ -n "${TINYSTT_SIGN_IDENTITY:-${TINYTTS_SIGN_IDENTITY:-}}" ]; then
|
||||||
echo "${TINYTTS_SIGN_IDENTITY}"
|
echo "${TINYSTT_SIGN_IDENTITY:-${TINYTTS_SIGN_IDENTITY}}"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
local valid
|
local valid
|
||||||
@@ -34,6 +34,11 @@ resolve_identity() {
|
|||||||
echo "${valid}" | grep "Apple Development" | head -1 | sed -E 's/.*"(.*)"/\1/'
|
echo "${valid}" | grep "Apple Development" | head -1 | sed -E 's/.*"(.*)"/\1/'
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
if echo "${valid}" | grep -q "TinySTT Dev"; then
|
||||||
|
echo "TinySTT Dev"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Keep using a pre-rename identity when one is already installed.
|
||||||
if echo "${valid}" | grep -q "TinyTTS Dev"; then
|
if echo "${valid}" | grep -q "TinyTTS Dev"; then
|
||||||
echo "TinyTTS Dev"
|
echo "TinyTTS Dev"
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,162 +1,18 @@
|
|||||||
#!/usr/bin/env swift
|
#!/usr/bin/env swift
|
||||||
//
|
// Package the TinySTT T-monogram source into all macOS icon sizes.
|
||||||
// make-icon.swift — 生成 TinyTTS 应用图标
|
// Usage: swift scripts/make-icon.swift Resources
|
||||||
//
|
|
||||||
// 设计:白色麦克风由三段「文字条」镂空而成 —— 声音落成文字。
|
|
||||||
// 深靛蓝渐变底 + 纯白图形,16pt 到 1024pt 都保持清晰轮廓。
|
|
||||||
//
|
|
||||||
// 用法:swift scripts/make-icon.swift <输出目录>
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let BASE: CGFloat = 1024
|
|
||||||
|
|
||||||
/// 超椭圆(squircle),n 越大越接近方形;n=4 接近 Apple 图标的连续曲率
|
|
||||||
func squirclePath(in rect: NSRect, n: CGFloat = 4.0, samples: Int = 720) -> NSBezierPath {
|
|
||||||
let path = NSBezierPath()
|
|
||||||
let a = rect.width / 2
|
|
||||||
let b = rect.height / 2
|
|
||||||
for i in 0...samples {
|
|
||||||
let t = CGFloat(i) / CGFloat(samples) * 2 * .pi
|
|
||||||
let ct = cos(t)
|
|
||||||
let st = sin(t)
|
|
||||||
let x = rect.midX + a * (ct < 0 ? -1 : 1) * pow(abs(ct), 2 / n)
|
|
||||||
let y = rect.midY + b * (st < 0 ? -1 : 1) * pow(abs(st), 2 / n)
|
|
||||||
if i == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) }
|
|
||||||
}
|
|
||||||
path.close()
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawIcon(size: CGFloat) -> NSBitmapImageRep {
|
|
||||||
let px = Int(size.rounded())
|
|
||||||
let rep = NSBitmapImageRep(
|
|
||||||
bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px,
|
|
||||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
|
|
||||||
isPlanar: false, colorSpaceName: .deviceRGB,
|
|
||||||
bytesPerRow: 0, bitsPerPixel: 0
|
|
||||||
)!
|
|
||||||
rep.size = NSSize(width: px, height: px)
|
|
||||||
|
|
||||||
NSGraphicsContext.saveGraphicsState()
|
|
||||||
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return rep }
|
|
||||||
NSGraphicsContext.current = ctx
|
|
||||||
ctx.imageInterpolation = .high
|
|
||||||
|
|
||||||
let s = CGFloat(px) / BASE
|
|
||||||
// 小尺寸线条会糊,做一点加粗补偿
|
|
||||||
let bold: CGFloat = px <= 32 ? 1.16 : (px <= 64 ? 1.06 : 1.0)
|
|
||||||
// 尺寸分级:越小越简化,否则 16/32px 会糊成一团
|
|
||||||
let isTiny = px <= 32
|
|
||||||
let isSmall = px <= 64
|
|
||||||
let plateInset: CGFloat = isTiny ? 52 : (isSmall ? 72 : 100)
|
|
||||||
let glyphScale: CGFloat = isTiny ? 1.34 : (isSmall ? 1.20 : 1.10)
|
|
||||||
let showHoles = px >= 128 // 镂空文字在小图上会糊成一格一格
|
|
||||||
let showGlyphShadow = px >= 128
|
|
||||||
let showPlateShadow = px >= 128
|
|
||||||
|
|
||||||
func len(_ v: CGFloat) -> CGFloat { v * s }
|
|
||||||
/// 1024 基准坐标,y 按「距顶部」度量
|
|
||||||
func P(_ x: CGFloat, _ yTop: CGFloat) -> NSPoint { NSPoint(x: x * s, y: (BASE - yTop) * s) }
|
|
||||||
/// 矩形(x, 距顶 y, 宽, 高)
|
|
||||||
func R(_ x: CGFloat, _ yTop: CGFloat, _ w: CGFloat, _ h: CGFloat) -> NSRect {
|
|
||||||
NSRect(x: len(x), y: (BASE - yTop - h) * s, width: len(w), height: len(h))
|
|
||||||
}
|
|
||||||
func roundRect(_ x: CGFloat, _ yTop: CGFloat, _ w: CGFloat, _ h: CGFloat, _ radius: CGFloat) -> NSBezierPath {
|
|
||||||
NSBezierPath(roundedRect: R(x, yTop, w, h), xRadius: len(radius), yRadius: len(radius))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: 底板
|
|
||||||
|
|
||||||
let inset = len(plateInset)
|
|
||||||
let plate = NSRect(x: inset, y: inset,
|
|
||||||
width: CGFloat(px) - inset * 2,
|
|
||||||
height: CGFloat(px) - inset * 2)
|
|
||||||
let shape = squirclePath(in: plate)
|
|
||||||
|
|
||||||
let shadow = NSShadow()
|
|
||||||
shadow.shadowColor = NSColor.black.withAlphaComponent(0.30)
|
|
||||||
shadow.shadowBlurRadius = len(24)
|
|
||||||
shadow.shadowOffset = NSSize(width: 0, height: -len(9))
|
|
||||||
if showPlateShadow { shadow.set() }
|
|
||||||
|
|
||||||
let topColor = NSColor(srgbRed: 0.38, green: 0.50, blue: 1.00, alpha: 1) // #617FFF
|
|
||||||
let bottomColor = NSColor(srgbRed: 0.21, green: 0.19, blue: 0.75, alpha: 1) // #3630BF
|
|
||||||
NSGradient(starting: topColor, ending: bottomColor)?.draw(in: shape, angle: -90)
|
|
||||||
|
|
||||||
NSShadow().set() // 之后不再带投影
|
|
||||||
|
|
||||||
// 顶部柔光:整块区域线性渐变,避免出现横向硬边
|
|
||||||
NSGraphicsContext.saveGraphicsState()
|
|
||||||
shape.addClip()
|
|
||||||
NSGradient(colors: [
|
|
||||||
NSColor.white.withAlphaComponent(0.20),
|
|
||||||
NSColor.white.withAlphaComponent(0.0),
|
|
||||||
])?.draw(in: plate, angle: -90)
|
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
|
||||||
|
|
||||||
NSColor.white.withAlphaComponent(0.10).setStroke()
|
|
||||||
shape.lineWidth = max(len(2), 0.5)
|
|
||||||
shape.stroke()
|
|
||||||
|
|
||||||
// MARK: 麦克风
|
|
||||||
|
|
||||||
let glyphShadow = NSShadow()
|
|
||||||
glyphShadow.shadowColor = NSColor.black.withAlphaComponent(0.20)
|
|
||||||
glyphShadow.shadowBlurRadius = len(14)
|
|
||||||
glyphShadow.shadowOffset = NSSize(width: 0, height: -len(7))
|
|
||||||
if showGlyphShadow { glyphShadow.set() }
|
|
||||||
|
|
||||||
NSColor.white.setFill()
|
|
||||||
NSColor.white.setStroke()
|
|
||||||
|
|
||||||
// 腔体:胶囊上挖出三条文字
|
|
||||||
let capX: CGFloat = 412, capW: CGFloat = 200
|
|
||||||
let capY: CGFloat = 225, capH: CGFloat = 300
|
|
||||||
NSGraphicsContext.current?.cgContext.concatenate(CGAffineTransform(a: glyphScale, b: 0, c: 0, d: glyphScale, tx: len(512) * (1 - glyphScale), ty: len(512) * (1 - glyphScale)))
|
|
||||||
let body = roundRect(capX, capY, capW, capH, capW / 2)
|
|
||||||
|
|
||||||
let lineX: CGFloat = 452
|
|
||||||
let lineH: CGFloat = 24 * bold
|
|
||||||
let lineY: [CGFloat] = [305, 363, 421]
|
|
||||||
let linePath = NSBezierPath()
|
|
||||||
for (i, y) in lineY.enumerated() {
|
|
||||||
let w: CGFloat = [120, 88, 104][i]
|
|
||||||
linePath.append(roundRect(lineX, y, w, lineH, lineH / 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
let bodyWithHoles = NSBezierPath()
|
|
||||||
bodyWithHoles.append(body)
|
|
||||||
if showHoles { bodyWithHoles.append(linePath) }
|
|
||||||
bodyWithHoles.windingRule = .evenOdd
|
|
||||||
bodyWithHoles.fill()
|
|
||||||
|
|
||||||
// 托架
|
|
||||||
let arc = NSBezierPath()
|
|
||||||
arc.appendArc(withCenter: P(512, 525), radius: len(148),
|
|
||||||
startAngle: 180, endAngle: 360, clockwise: false)
|
|
||||||
arc.lineWidth = len(54 * bold)
|
|
||||||
arc.lineCapStyle = .round
|
|
||||||
arc.stroke()
|
|
||||||
|
|
||||||
// 立柱
|
|
||||||
roundRect(512 - 27, 660, 54, 92, 27).fill()
|
|
||||||
|
|
||||||
// 底座
|
|
||||||
roundRect(402, 745, 220, 54 * bold, 27 * bold).fill()
|
|
||||||
|
|
||||||
NSShadow().set()
|
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
|
||||||
return rep
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 输出
|
// MARK: - 输出
|
||||||
|
|
||||||
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Resources"
|
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Resources"
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let iconset = (outDir as NSString).appendingPathComponent("AppIcon.iconset")
|
let iconset = (outDir as NSString).appendingPathComponent("AppIcon.iconset")
|
||||||
|
let sourceURL = URL(fileURLWithPath: outDir).appendingPathComponent("AppIcon-source.png")
|
||||||
|
guard let source = NSImage(contentsOf: sourceURL) else {
|
||||||
|
fatalError("缺少图标源文件:\(sourceURL.path)")
|
||||||
|
}
|
||||||
|
|
||||||
try? fm.removeItem(atPath: iconset)
|
try? fm.removeItem(atPath: iconset)
|
||||||
try fm.createDirectory(atPath: iconset, withIntermediateDirectories: true)
|
try fm.createDirectory(atPath: iconset, withIntermediateDirectories: true)
|
||||||
@@ -170,7 +26,16 @@ let variants: [(String, Int)] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (name, px) in variants {
|
for (name, px) in variants {
|
||||||
let rep = drawIcon(size: CGFloat(px))
|
let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px,
|
||||||
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
|
||||||
|
isPlanar: false, colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0, bitsPerPixel: 0)!
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||||
|
NSGraphicsContext.current?.imageInterpolation = .high
|
||||||
|
source.draw(in: NSRect(x: 0, y: 0, width: px, height: px),
|
||||||
|
from: .zero, operation: .copy, fraction: 1)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||||
FileHandle.standardError.write("生成 \(name) 失败\n".data(using: .utf8)!)
|
FileHandle.standardError.write("生成 \(name) 失败\n".data(using: .utf8)!)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# 生成一个本地自签名代码签名证书,保证每次重建后「辅助功能」授权不会失效。
|
# 生成一个本地自签名代码签名证书,保证每次重建后「辅助功能」授权不会失效。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IDENTITY="${TINYTTS_SIGN_IDENTITY:-TinyTTS Dev}"
|
IDENTITY="${TINYSTT_SIGN_IDENTITY:-${TINYTTS_SIGN_IDENTITY:-TinySTT Dev}}"
|
||||||
|
|
||||||
if security find-identity -v -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then
|
if security find-identity -v -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then
|
||||||
echo "签名身份已存在:$IDENTITY"
|
echo "签名身份已存在:$IDENTITY"
|
||||||
@@ -18,7 +18,7 @@ distinguished_name = dn
|
|||||||
x509_extensions = ext
|
x509_extensions = ext
|
||||||
prompt = no
|
prompt = no
|
||||||
[dn]
|
[dn]
|
||||||
CN = TinyTTS Dev
|
CN = TinySTT Dev
|
||||||
[ext]
|
[ext]
|
||||||
basicConstraints = critical,CA:false
|
basicConstraints = critical,CA:false
|
||||||
keyUsage = critical,digitalSignature
|
keyUsage = critical,digitalSignature
|
||||||
@@ -31,15 +31,15 @@ openssl req -x509 -newkey rsa:2048 -nodes \
|
|||||||
|
|
||||||
openssl pkcs12 -export -out "$WORK/id.p12" \
|
openssl pkcs12 -export -out "$WORK/id.p12" \
|
||||||
-inkey "$WORK/key.pem" -in "$WORK/cert.pem" \
|
-inkey "$WORK/key.pem" -in "$WORK/cert.pem" \
|
||||||
-name "$IDENTITY" -passout pass:tinytss \
|
-name "$IDENTITY" -passout pass:tinystt \
|
||||||
-legacy -macalg sha1 -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES >/dev/null 2>&1 \
|
-legacy -macalg sha1 -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES >/dev/null 2>&1 \
|
||||||
|| openssl pkcs12 -export -out "$WORK/id.p12" \
|
|| openssl pkcs12 -export -out "$WORK/id.p12" \
|
||||||
-inkey "$WORK/key.pem" -in "$WORK/cert.pem" \
|
-inkey "$WORK/key.pem" -in "$WORK/cert.pem" \
|
||||||
-name "$IDENTITY" -passout pass:tinytss \
|
-name "$IDENTITY" -passout pass:tinystt \
|
||||||
-macalg sha1 -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES >/dev/null 2>&1
|
-macalg sha1 -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES >/dev/null 2>&1
|
||||||
|
|
||||||
security import "$WORK/id.p12" -k "$HOME/Library/Keychains/login.keychain-db" \
|
security import "$WORK/id.p12" -k "$HOME/Library/Keychains/login.keychain-db" \
|
||||||
-P tinytss -T /usr/bin/codesign >/dev/null
|
-P tinystt -T /usr/bin/codesign >/dev/null
|
||||||
|
|
||||||
echo "已创建签名身份:$IDENTITY"
|
echo "已创建签名身份:$IDENTITY"
|
||||||
security find-identity -v -p codesigning | grep "$IDENTITY" || true
|
security find-identity -v -p codesigning | grep "$IDENTITY" || true
|
||||||