feat(keyboard): add Notes skill and system-style typing
Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
This commit is contained in:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### Version format
|
||||
|
||||
The current source-of-truth version is **1.7.5 (build 69)**. Releases use stable SemVer:
|
||||
The current source-of-truth version is **1.7.5 (build 71)**. Releases use stable SemVer:
|
||||
|
||||
| Field | File | Rule |
|
||||
|-------|------|------|
|
||||
|
||||
+11
-3
@@ -8,19 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Overlapping key presses**: the typing grid tracks multiple fingers, so the next key can go down before the previous lifts. Pending letters commit in press order (not release order); Shift can be held with one finger while another types. / **叠指连打**:打字网格跟踪多指,上一键未松开也可按下下一键。未提交的字母按按下顺序出字(而非抬手顺序);一只手指按住 Shift 时另一只可打字。
|
||||
- **Period shortcut**: in English, a second Space shortly after a Space that follows a word becomes `. ` and arms sentence Shift, matching the system "." Shortcut. / **句号快捷**:英文下,在单词后的空格上短时间内再按一次空格会变成 `. ` 并点亮句首 Shift,对齐系统「句号快捷」。
|
||||
- **Return key labels**: Go / Search / Send / Done / Next / Join and the other `UIReturnKeyType` values show their system captions on the green action key instead of collapsing to Send or a return arrow. / **回车键文案**:前往 / 搜索 / 发送 / 完成 / 下一项 / 加入等 `UIReturnKeyType` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。
|
||||
- **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。
|
||||
- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
|
||||
- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。
|
||||
- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。
|
||||
- **Extract tasks skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractTodos` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开 App 内置的配套捷径 `OSGExtractTodos` 系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
|
||||
- **Extract events skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractEvents` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开 App 内置的配套捷径 `OSGExtractEvents` 系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。
|
||||
- **Navigate skill**: copying text and tapping Navigate asks the LLM for one address (or origin → destination) and the host app opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No companion Shortcut. No address stays in the current app with a keyboard tip. / **导航技能**:复制文字并点「导航」会抽取一条地址(或起点→终点),由 App 直接打开驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。不需要配套捷径。没有地址则留在当前 App,键盘上给出提示。
|
||||
- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。
|
||||
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
|
||||
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
|
||||
|
||||
### Fixed
|
||||
- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。
|
||||
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
|
||||
- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
|
||||
- **Skill drag preview corners**: the lift preview clips to the card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
|
||||
|
||||
### Changed
|
||||
- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos` 和 `OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。
|
||||
- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。
|
||||
- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。
|
||||
- **Skill and style cards**: every card uses a top-right edit pencil and a bottom-right selected check; skill Done sits on the right like style details; tapping a skill card toggles the keyboard slot (unconfirmed Shortcut skills still open the install sheet). / **技能与风格卡片**:右上为编辑铅笔、右下为选中勾选;技能详情「完成」改到右侧;点技能卡片切换是否上键盘(未确认捷径的仍打开安装页)。
|
||||
- **Clipboard skill grid**: idle skill chips stay as one centered block, wrapping to two rows of up to four instead of scrolling sideways after four. / **剪贴板技能网格**:空闲技能按钮作为一整块居中,超过四个改为两行(每行最多四个),不再横向滚动。
|
||||
- **Skill sheet buttons**: Add, Turn off, Add Shortcut, I’ve added it, and Reinstall are solid capsules — primary actions use the brand green (`#3AA05A`), secondary actions use an elevated fill. / **技能弹窗按钮**:添加、关闭、添加捷径、我已添加、重新安装改为实心胶囊;主操作为品牌绿(`#3AA05A`),次要为抬升底。
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,8 @@
|
||||
// AIAgentShortcutRunner.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Consumes the keyboard's pending export-skill payload and opens the
|
||||
// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
|
||||
// x-callback URLs so Console can record success / error / cancel.
|
||||
// Consumes the keyboard's pending export-skill payload. Navigate opens a
|
||||
// map URL in the host. Other exports open the companion Shortcut.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
@@ -14,19 +13,23 @@ enum AIAgentShortcutRunner {
|
||||
AIAgentShortcutRun.trace("host.runPending begin")
|
||||
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
|
||||
let catalog = AppGroupStore().agentUserSkillCatalog
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog),
|
||||
let name = skill.shortcutName else {
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else {
|
||||
AIAgentShortcutRun.trace(
|
||||
"host.runPending skip unknownSkill=\(payload.skillID)"
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let text = shortcutText(for: skill, payload: payload) else {
|
||||
if skill.id == AIClipboardSkillCatalog.navigateID {
|
||||
openMap(for: payload)
|
||||
return
|
||||
}
|
||||
guard let name = skill.shortcutName else {
|
||||
AIAgentShortcutRun.trace(
|
||||
"host.runPending skip textBuildFailed skill=\(payload.skillID)"
|
||||
"host.runPending skip missingShortcut skill=\(payload.skillID)"
|
||||
)
|
||||
return
|
||||
}
|
||||
let text = payload.joinedTitles
|
||||
AIAgentShortcutRun.traceBody("host.titlesToShortcut", text)
|
||||
guard let url = shortcutsURL(name: name, text: text) else {
|
||||
AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
|
||||
@@ -62,19 +65,22 @@ enum AIAgentShortcutRunner {
|
||||
)
|
||||
}
|
||||
|
||||
/// Navigate: pick 高德 → 百度 → Apple Maps, pass that URL to the Shortcut.
|
||||
/// Other export skills send the parsed lines unchanged.
|
||||
private static func shortcutText(
|
||||
for skill: AIClipboardSkill,
|
||||
payload: AIAgentShortcutRunPayload
|
||||
) -> String? {
|
||||
if skill.id == AIClipboardSkillCatalog.navigateID {
|
||||
return AIMapNavigation.shortcutInput(
|
||||
from: payload.joinedTitles,
|
||||
canOpen: { UIApplication.shared.canOpenURL($0) }
|
||||
)
|
||||
/// 高德 → 百度 → Apple Maps. Do not bounce through Shortcuts.
|
||||
private static func openMap(for payload: AIAgentShortcutRunPayload) {
|
||||
guard let urlString = AIMapNavigation.shortcutInput(
|
||||
from: payload.joinedTitles,
|
||||
canOpen: { UIApplication.shared.canOpenURL($0) }
|
||||
), let url = URL(string: urlString) else {
|
||||
AIAgentShortcutRun.trace("host.runPending skip navigateURLBuildFailed")
|
||||
return
|
||||
}
|
||||
AIAgentShortcutRun.trace(
|
||||
"host.openMap scheme=\(url.scheme ?? "") host=\(url.host ?? "")"
|
||||
)
|
||||
AIAgentShortcutRun.traceBody("host.mapURL", urlString)
|
||||
UIApplication.shared.open(url) { success in
|
||||
AIAgentShortcutRun.trace("host.openMap result success=\(success)")
|
||||
}
|
||||
return payload.joinedTitles
|
||||
}
|
||||
|
||||
private static func shortcutsURL(name: String, text: String) -> URL? {
|
||||
|
||||
@@ -15,23 +15,6 @@ enum AppTab: Int, CaseIterable {
|
||||
case styles
|
||||
case settings
|
||||
|
||||
var icon: MaterialIconName {
|
||||
switch self {
|
||||
case .keyboard: return .keyboard
|
||||
case .skills, .styles: return .menuBook // unused — these tabs use SF Symbols
|
||||
case .settings: return .settings
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol overrides shared with the Mac and iPad sidebars.
|
||||
var sfSymbol: String? {
|
||||
switch self {
|
||||
case .skills: return "sparkles"
|
||||
case .styles: return "text.badge.star"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityKey: LocalizedStringKey {
|
||||
switch self {
|
||||
case .keyboard: return "tab.keyboard"
|
||||
@@ -41,15 +24,28 @@ enum AppTab: Int, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone dock: outline when idle, fill when selected (system tab-bar
|
||||
/// convention). `wand.and.sparkles` has no `.fill` pair.
|
||||
func dockSystemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
|
||||
case .skills: return "wand.and.sparkles"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
||||
|
||||
var sidebarSystemImage: String {
|
||||
/// iPad sidebar. Home stays `house` in both states; other tabs follow
|
||||
/// the same outline / fill pairing as the phone dock.
|
||||
func sidebarSystemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .keyboard: return "house"
|
||||
case .skills: return "sparkles"
|
||||
case .styles: return "text.badge.star"
|
||||
case .settings: return "gearshape"
|
||||
case .skills: return "wand.and.sparkles"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,14 +65,8 @@ struct MinimalTabBar: View {
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
Group {
|
||||
if let sfSymbol = tab.sfSymbol {
|
||||
Image(systemName: sfSymbol)
|
||||
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
|
||||
} else {
|
||||
MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize)
|
||||
}
|
||||
}
|
||||
Image(systemName: tab.dockSystemImage(selected: selection == tab))
|
||||
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
|
||||
Text(tab.accessibilityKey)
|
||||
.font(TypeStyle.caption2)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -52,13 +52,13 @@ extension View {
|
||||
}
|
||||
|
||||
enum TabBarDockMetrics {
|
||||
static let itemHeight: CGFloat = 52
|
||||
static let iconSize: CGFloat = 24
|
||||
static let itemHeight: CGFloat = 46
|
||||
static let iconSize: CGFloat = 22
|
||||
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
|
||||
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~3–4×
|
||||
/// the 5 pt top/bottom gap.
|
||||
static let dockInsetHorizontal: CGFloat = 0
|
||||
static let dockInsetVertical: CGFloat = Spacing.sm
|
||||
static let dockInsetVertical: CGFloat = Spacing.xs
|
||||
/// Gap between the selected fill and the glass dock / neighbouring tabs.
|
||||
static let selectionInset: CGFloat = 5
|
||||
static let bottomPadding: CGFloat = Spacing.xs
|
||||
|
||||
@@ -110,7 +110,7 @@ private struct WideSidebarRow: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
|
||||
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage(selected: isSelected))
|
||||
.labelStyle(SidebarIconColumnLabelStyle())
|
||||
// Slightly larger label + taller vertical padding so each
|
||||
// sidebar row hits the Apple HIG 44pt touch target on iPad
|
||||
@@ -144,7 +144,7 @@ private struct WideSidebarRow: View {
|
||||
/// (`house`) — enough to push titles apart by ~5pt. `List` reserves that column
|
||||
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
|
||||
private struct SidebarIconColumnLabelStyle: LabelStyle {
|
||||
/// Wider than the widest symbol in `AppTab.sidebarSystemImage`.
|
||||
/// Wider than the widest symbol in `AppTab.sidebarSystemImage(selected:)`.
|
||||
private static let iconColumnWidth: CGFloat = 22
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
|
||||
@@ -457,6 +457,8 @@
|
||||
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
||||
"skills.extractEvents.name" = "Extract events";
|
||||
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
|
||||
"skills.saveToNotes.name" = "Save to Notes";
|
||||
"skills.saveToNotes.description" = "Create a new note from the copied text. The title comes from the time and content; the body stays as copied.";
|
||||
"skills.navigate.name" = "Navigate";
|
||||
"skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps.";
|
||||
"skills.install.lead" = "Add it, then confirm. Don’t rename it.";
|
||||
|
||||
@@ -456,6 +456,8 @@
|
||||
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
||||
"skills.extractEvents.name" = "提取日程";
|
||||
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
|
||||
"skills.saveToNotes.name" = "存入备忘录";
|
||||
"skills.saveToNotes.description" = "根据复制内容和当前时间生成标题,写入一条新的备忘录;正文保持原文。";
|
||||
"skills.navigate.name" = "导航";
|
||||
"skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。";
|
||||
"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
|
||||
|
||||
@@ -773,10 +773,30 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
|
||||
switch returnKeyType {
|
||||
case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall:
|
||||
return .send
|
||||
case .default, .next, .done:
|
||||
case .default:
|
||||
return .newline
|
||||
case .go:
|
||||
return .go
|
||||
case .google:
|
||||
return .google
|
||||
case .join:
|
||||
return .join
|
||||
case .next:
|
||||
return .next
|
||||
case .route:
|
||||
return .route
|
||||
case .search:
|
||||
return .search
|
||||
case .send:
|
||||
return .send
|
||||
case .yahoo:
|
||||
return .yahoo
|
||||
case .done:
|
||||
return .done
|
||||
case .emergencyCall:
|
||||
return .emergencyCall
|
||||
case .continue:
|
||||
return .continue
|
||||
@unknown default:
|
||||
return .newline
|
||||
}
|
||||
|
||||
@@ -70,13 +70,18 @@ final class AIKeyboardCoordinator {
|
||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
enterIfNeeded()
|
||||
state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil
|
||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
if skill.kind == .export {
|
||||
state.pendingClipboardSkillID = skill.id
|
||||
state.pendingClipboardSkillSource = material
|
||||
} else {
|
||||
clearPendingExportSkill()
|
||||
}
|
||||
let instruction = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: AIHintLocaleResolver.packLocale(),
|
||||
translationTargetLocaleId: state.translationTargetLocaleId
|
||||
)
|
||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
||||
if let material {
|
||||
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
||||
@@ -120,7 +125,7 @@ final class AIKeyboardCoordinator {
|
||||
) {
|
||||
guard case .ready(let prompt) = resolution else {
|
||||
// The clipboard window closed between rendering and this tap.
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
state.aiSession.fail(
|
||||
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
|
||||
utteranceID: nil
|
||||
@@ -128,7 +133,7 @@ final class AIKeyboardCoordinator {
|
||||
return
|
||||
}
|
||||
guard let conversationID = state.aiSession.conversationID else {
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
return
|
||||
}
|
||||
let disposition = flow.submitAIQuestion(
|
||||
@@ -137,14 +142,14 @@ final class AIKeyboardCoordinator {
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
guard state.aiSession.isBusy else { return }
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
flow.cancelAIRecording()
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
@@ -153,7 +158,7 @@ final class AIKeyboardCoordinator {
|
||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||
guard insertAnswer(answer) else { return }
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole == .send
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
} else if state.aiSession.canSend {
|
||||
state.aiSession.markAnswerSent()
|
||||
@@ -228,16 +233,21 @@ final class AIKeyboardCoordinator {
|
||||
}
|
||||
|
||||
func fail(_ message: String, utteranceID: UUID?) {
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
private func endConversationIfNeeded() {
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
flow.endAIConversation(conversationID)
|
||||
}
|
||||
|
||||
private func clearPendingExportSkill() {
|
||||
state.pendingClipboardSkillID = nil
|
||||
state.pendingClipboardSkillSource = nil
|
||||
}
|
||||
|
||||
private var isPendingExportSkill: Bool {
|
||||
guard let id = state.pendingClipboardSkillID else { return false }
|
||||
return resolvedSkill(id: id)?.kind == .export
|
||||
@@ -251,9 +261,10 @@ final class AIKeyboardCoordinator {
|
||||
}
|
||||
|
||||
/// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
|
||||
/// Lines → hand off to the host to run the companion Shortcut.
|
||||
/// Lines → hand off to the host (Shortcut, Maps, or Didi).
|
||||
private func finishExportSkill(answer: String) {
|
||||
let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
let source = state.pendingClipboardSkillSource
|
||||
?? ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
let skillID = state.pendingClipboardSkillID
|
||||
let items: [String]
|
||||
let emptyTipKey: String
|
||||
@@ -267,6 +278,13 @@ final class AIKeyboardCoordinator {
|
||||
case AIClipboardSkillCatalog.navigateID:
|
||||
items = AIAddressExtraction.lines(from: answer, sourceClipboard: source)
|
||||
emptyTipKey = "keyboard.ai.skill.noAddress"
|
||||
case AIClipboardSkillCatalog.saveToNotesID:
|
||||
items = AINoteExport.items(
|
||||
from: answer,
|
||||
sourceClipboard: source,
|
||||
locale: AIHintLocaleResolver.packLocale()
|
||||
)
|
||||
emptyTipKey = "keyboard.ai.skill.noNote"
|
||||
default:
|
||||
items = AIGenericSkillExport.items(from: answer)
|
||||
emptyTipKey = "keyboard.ai.skill.noExportItems"
|
||||
@@ -280,7 +298,7 @@ final class AIKeyboardCoordinator {
|
||||
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
|
||||
}
|
||||
#endif
|
||||
state.pendingClipboardSkillID = nil
|
||||
clearPendingExportSkill()
|
||||
if state.aiSession.isBusy {
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
@@ -292,14 +310,25 @@ final class AIKeyboardCoordinator {
|
||||
state.skillTipText = ExtL10n.string(emptyTipKey)
|
||||
return
|
||||
}
|
||||
guard let skillID,
|
||||
resolvedSkill(id: skillID)?.shortcutName != nil else {
|
||||
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
|
||||
guard let skillID, let skill = resolvedSkill(id: skillID) else {
|
||||
AIAgentShortcutRun.trace("keyboard.parse missingSkill")
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
||||
return
|
||||
}
|
||||
if skill.requiresShortcut, skill.shortcutName == nil {
|
||||
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID)")
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
||||
return
|
||||
}
|
||||
AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)")
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut")
|
||||
let tipKey: String
|
||||
switch skillID {
|
||||
case AIClipboardSkillCatalog.navigateID:
|
||||
tipKey = "keyboard.ai.skill.openingMaps"
|
||||
default:
|
||||
tipKey = "keyboard.ai.skill.runningShortcut"
|
||||
}
|
||||
state.skillTipText = ExtL10n.string(tipKey)
|
||||
state.runClipboardExportSkill(skillID, items)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// Grid-level UIKit touch tracking for the typing surface:
|
||||
// Down highlight → Move reselect → Up commit (letters / space / return),
|
||||
// delete repeats on down, Shift holds while the gesture owns it.
|
||||
// overlapping fingers commit in press order, delete repeats on down,
|
||||
// Shift holds while that finger owns it.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -12,7 +13,7 @@ import OSGKeyboardShared
|
||||
struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
var layout: TypingKeyLayout
|
||||
var hapticIntensity: KeyboardHapticIntensity
|
||||
var onHighlightChange: (String?) -> Void
|
||||
var onHighlightChange: (Set<String>) -> Void
|
||||
var onCommit: (TypingKeyHitTarget) -> Void
|
||||
var onDeleteFire: () -> Void
|
||||
var onShiftBegan: () -> Void
|
||||
@@ -36,8 +37,7 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
@MainActor
|
||||
final class Coordinator {
|
||||
var parent: TypingKeyTouchPad
|
||||
private var activeKeyID: String?
|
||||
private var gestureOwnsShift = false
|
||||
private let tracker = TypingTouchTracker()
|
||||
private var deleteRepeatTask: Task<Void, Never>?
|
||||
private var deleteRepeatStartedAt: Date?
|
||||
private var isDeleteRepeating = false
|
||||
@@ -46,72 +46,24 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func handleBegan(at point: CGPoint) {
|
||||
resetDeleteRepeat()
|
||||
gestureOwnsShift = false
|
||||
guard let key = hit(at: point) else {
|
||||
setHighlight(nil)
|
||||
return
|
||||
}
|
||||
activate(key)
|
||||
func handleBegan(id: ObjectIdentifier, at point: CGPoint) {
|
||||
apply(tracker.began(id: id, key: hit(at: point)))
|
||||
}
|
||||
|
||||
func handleMoved(at point: CGPoint) {
|
||||
let key = hit(at: point)
|
||||
if key?.id == activeKeyID { return }
|
||||
|
||||
if activeBehavior == .deleteRepeat {
|
||||
stopDeleteRepeat()
|
||||
}
|
||||
|
||||
if let key {
|
||||
activate(key)
|
||||
} else {
|
||||
// Outside plane: clear highlight; Shift stays held until ended.
|
||||
setHighlight(nil)
|
||||
activeKeyID = nil
|
||||
}
|
||||
func handleMoved(id: ObjectIdentifier, at point: CGPoint) {
|
||||
apply(tracker.moved(id: id, key: hit(at: point)))
|
||||
}
|
||||
|
||||
func handleEnded(at point: CGPoint) {
|
||||
// Outside the key plane → cancel (no commit), per accuracy plan.
|
||||
let key = hit(at: point)
|
||||
stopDeleteRepeat()
|
||||
|
||||
defer {
|
||||
setHighlight(nil)
|
||||
activeKeyID = nil
|
||||
finishShiftIfNeeded()
|
||||
}
|
||||
|
||||
guard let key else { return }
|
||||
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
parent.onCommit(key)
|
||||
case .deleteRepeat:
|
||||
// Already fired on down / while held.
|
||||
break
|
||||
case .shiftHold:
|
||||
// endShiftHold decides tap vs hold-with-type.
|
||||
break
|
||||
}
|
||||
func handleEnded(id: ObjectIdentifier, at point: CGPoint) {
|
||||
apply(tracker.ended(id: id, key: hit(at: point)))
|
||||
}
|
||||
|
||||
func handleCancelled() {
|
||||
stopDeleteRepeat()
|
||||
setHighlight(nil)
|
||||
activeKeyID = nil
|
||||
finishShiftIfNeeded()
|
||||
func handleCancelled(id: ObjectIdentifier) {
|
||||
apply(tracker.cancelled(id: id))
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private var activeBehavior: TypingKeyTouchBehavior? {
|
||||
guard let activeKeyID else { return nil }
|
||||
return parent.layout.key(id: activeKeyID)?.behavior
|
||||
}
|
||||
|
||||
private func hit(at point: CGPoint) -> TypingKeyHitTarget? {
|
||||
let layout = parent.layout
|
||||
return KeyHitTesting.hitTarget(
|
||||
@@ -124,27 +76,29 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
)
|
||||
}
|
||||
|
||||
private func activate(_ key: TypingKeyHitTarget) {
|
||||
activeKeyID = key.id
|
||||
setHighlight(key.id)
|
||||
playFeedback(for: key)
|
||||
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
break
|
||||
case .deleteRepeat:
|
||||
parent.onDeleteFire()
|
||||
startDeleteRepeat()
|
||||
case .shiftHold:
|
||||
if !gestureOwnsShift {
|
||||
gestureOwnsShift = true
|
||||
parent.onShiftBegan()
|
||||
}
|
||||
private func apply(_ effects: TypingTouchEffects) {
|
||||
if effects.stopDeleteRepeat {
|
||||
stopDeleteRepeat()
|
||||
}
|
||||
}
|
||||
|
||||
private func setHighlight(_ id: String?) {
|
||||
parent.onHighlightChange(id)
|
||||
for key in effects.commits {
|
||||
parent.onCommit(key)
|
||||
}
|
||||
if let key = effects.playFeedback {
|
||||
playFeedback(for: key)
|
||||
}
|
||||
if effects.deleteFire {
|
||||
parent.onDeleteFire()
|
||||
}
|
||||
if effects.startDeleteRepeat {
|
||||
startDeleteRepeat()
|
||||
}
|
||||
if effects.beginShift {
|
||||
parent.onShiftBegan()
|
||||
}
|
||||
if effects.endShift {
|
||||
parent.onShiftEnded()
|
||||
}
|
||||
parent.onHighlightChange(tracker.highlightedKeyIDs)
|
||||
}
|
||||
|
||||
private func playFeedback(for key: TypingKeyHitTarget) {
|
||||
@@ -207,16 +161,6 @@ struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
deleteRepeatTask?.cancel()
|
||||
deleteRepeatTask = nil
|
||||
}
|
||||
|
||||
private func resetDeleteRepeat() {
|
||||
stopDeleteRepeat()
|
||||
}
|
||||
|
||||
private func finishShiftIfNeeded() {
|
||||
guard gestureOwnsShift else { return }
|
||||
gestureOwnsShift = false
|
||||
parent.onShiftEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +183,7 @@ final class TypingKeyTouchPadUIView: UIView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = Self.padTint
|
||||
isMultipleTouchEnabled = false
|
||||
isMultipleTouchEnabled = true
|
||||
isExclusiveTouch = true
|
||||
isUserInteractionEnabled = true
|
||||
}
|
||||
@@ -250,21 +194,30 @@ final class TypingKeyTouchPadUIView: UIView {
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
coordinator?.handleBegan(at: touch.location(in: self))
|
||||
for touch in Self.sorted(touches) {
|
||||
coordinator?.handleBegan(id: ObjectIdentifier(touch), at: touch.location(in: self))
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
coordinator?.handleMoved(at: touch.location(in: self))
|
||||
for touch in Self.sorted(touches) {
|
||||
coordinator?.handleMoved(id: ObjectIdentifier(touch), at: touch.location(in: self))
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
coordinator?.handleEnded(at: touch.location(in: self))
|
||||
for touch in Self.sorted(touches) {
|
||||
coordinator?.handleEnded(id: ObjectIdentifier(touch), at: touch.location(in: self))
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
coordinator?.handleCancelled()
|
||||
for touch in Self.sorted(touches) {
|
||||
coordinator?.handleCancelled(id: ObjectIdentifier(touch))
|
||||
}
|
||||
}
|
||||
|
||||
private static func sorted(_ touches: Set<UITouch>) -> [UITouch] {
|
||||
touches.sorted { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ struct TypingRootView: View {
|
||||
/// After the first expand, keep the panel tree mounted and only toggle
|
||||
/// opacity so subsequent ▼/▲ taps stay cheap.
|
||||
@State private var candidatePanelMounted = false
|
||||
/// Key currently under the finger (grid-level touch pad).
|
||||
@State private var highlightedKeyID: String?
|
||||
/// Keys currently under a finger (grid-level touch pad, multi-touch).
|
||||
@State private var highlightedKeyIDs: Set<String> = []
|
||||
|
||||
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
|
||||
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
@@ -398,7 +398,7 @@ struct TypingRootView: View {
|
||||
TypingKeyTouchPad(
|
||||
layout: layout,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
onHighlightChange: { highlightedKeyID = $0 },
|
||||
onHighlightChange: { highlightedKeyIDs = $0 },
|
||||
onCommit: { commitTypingKey($0) },
|
||||
onDeleteFire: { apply(typing.handleKey("⌫")) },
|
||||
onShiftBegan: { typing.beginShiftHold() },
|
||||
@@ -448,10 +448,10 @@ struct TypingRootView: View {
|
||||
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
||||
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
||||
let returnLabel: String = {
|
||||
switch state.returnKeyRole {
|
||||
case .newline: return "return"
|
||||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
if state.returnKeyRole.usesActionFill {
|
||||
return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
}
|
||||
return "return"
|
||||
}()
|
||||
|
||||
// iPad spends its extra width on comma / period like the system
|
||||
@@ -491,7 +491,7 @@ struct TypingRootView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
|
||||
let pressed = highlightedKeyID == key.id
|
||||
let pressed = highlightedKeyIDs.contains(key.id)
|
||||
let isBottom = key.id.hasPrefix("bottom.")
|
||||
let isShift = key.label == "⇧"
|
||||
let shiftLit = isShift && typing.isShiftEnabled
|
||||
@@ -624,13 +624,14 @@ struct TypingRootView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var returnKeyLabel: some View {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
Image(systemName: "arrow.turn.down.left")
|
||||
.font(.system(size: 21, weight: .medium))
|
||||
case .send:
|
||||
if state.returnKeyRole.usesActionFill {
|
||||
ExtL10n.text(state.returnKeyRole.titleKey)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
} else {
|
||||
Image(systemName: "arrow.turn.down.left")
|
||||
.font(.system(size: 21, weight: .medium))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,42 +685,29 @@ struct TypingRootView: View {
|
||||
}
|
||||
|
||||
private var returnKeyFill: Color {
|
||||
switch state.returnKeyRole {
|
||||
case .newline: return keyFill
|
||||
case .send: return sendKeyFill
|
||||
}
|
||||
state.returnKeyRole.usesActionFill ? sendKeyFill : keyFill
|
||||
}
|
||||
|
||||
private var returnKeyPressedFill: Color {
|
||||
switch state.returnKeyRole {
|
||||
case .newline: return keyPressedFill
|
||||
case .send: return sendKeyPressedFill
|
||||
}
|
||||
state.returnKeyRole.usesActionFill ? sendKeyPressedFill : keyPressedFill
|
||||
}
|
||||
|
||||
private var returnKeyBorder: Color {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
return palette.divider
|
||||
case .send:
|
||||
if state.returnKeyRole.usesActionFill {
|
||||
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
||||
}
|
||||
return palette.divider
|
||||
}
|
||||
|
||||
private var returnKeyAccessibilityLabel: String {
|
||||
switch state.returnKeyRole {
|
||||
case .newline: return "return"
|
||||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
if state.returnKeyRole.usesActionFill {
|
||||
return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
}
|
||||
return "return"
|
||||
}
|
||||
|
||||
private var returnKeyTextColor: Color {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
return keyTextColor
|
||||
case .send:
|
||||
return .white
|
||||
}
|
||||
state.returnKeyRole.usesActionFill ? .white : keyTextColor
|
||||
}
|
||||
|
||||
/// The send key stays recognizable in both appearances without becoming neon.
|
||||
|
||||
@@ -518,6 +518,9 @@ struct AIKeyboardView: View {
|
||||
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID {
|
||||
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
|
||||
}
|
||||
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.saveToNotesID {
|
||||
return ExtL10n.string("keyboard.ai.skill.namingNote")
|
||||
}
|
||||
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
||||
return ExtL10n.string("keyboard.ai.generating")
|
||||
}
|
||||
|
||||
@@ -484,7 +484,7 @@ public struct KeyboardRootView: View {
|
||||
title: title,
|
||||
label: title,
|
||||
disabled: disabled,
|
||||
isSend: state.returnKeyRole == .send,
|
||||
isSend: state.returnKeyRole.usesActionFill,
|
||||
hapticIntensity: state.keyboardHapticIntensity
|
||||
) {
|
||||
state.insertNewline()
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
"common.space" = "Space";
|
||||
"common.newline" = "Return";
|
||||
"common.send" = "Send";
|
||||
"keyboard.return.go" = "Go";
|
||||
"keyboard.return.search" = "Search";
|
||||
"keyboard.return.join" = "Join";
|
||||
"keyboard.return.next" = "Next";
|
||||
"keyboard.return.route" = "Route";
|
||||
"keyboard.return.google" = "Google";
|
||||
"keyboard.return.yahoo" = "Yahoo";
|
||||
"keyboard.return.emergencyCall" = "Emergency Call";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "On-device transcription";
|
||||
@@ -299,6 +307,7 @@
|
||||
"keyboard.ai.skill.translate" = "Translate";
|
||||
"keyboard.ai.skill.extractTodos" = "Tasks";
|
||||
"keyboard.ai.skill.extractEvents" = "Events";
|
||||
"keyboard.ai.skill.saveToNotes" = "Notes";
|
||||
"keyboard.ai.skill.navigate" = "Navigate";
|
||||
"keyboard.ai.skill.previewPolish" = "Polish";
|
||||
"keyboard.ai.skill.previewIdeas" = "Ideas";
|
||||
@@ -306,10 +315,13 @@
|
||||
"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
|
||||
"keyboard.ai.skill.noEvents" = "No date or time in the clipboard";
|
||||
"keyboard.ai.skill.noAddress" = "No address in the clipboard";
|
||||
"keyboard.ai.skill.noNote" = "Nothing to save to Notes";
|
||||
"keyboard.ai.skill.noExportItems" = "Nothing to send to the Shortcut";
|
||||
"keyboard.ai.skill.extracting" = "Finding tasks…";
|
||||
"keyboard.ai.skill.extractingEvents" = "Finding events…";
|
||||
"keyboard.ai.skill.extractingAddress" = "Finding an address…";
|
||||
"keyboard.ai.skill.namingNote" = "Naming the note…";
|
||||
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
|
||||
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
|
||||
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
|
||||
"keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut";
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
"common.space" = "空格";
|
||||
"common.newline" = "回车";
|
||||
"common.send" = "发送";
|
||||
"keyboard.return.go" = "前往";
|
||||
"keyboard.return.search" = "搜索";
|
||||
"keyboard.return.join" = "加入";
|
||||
"keyboard.return.next" = "下一项";
|
||||
"keyboard.return.route" = "路线";
|
||||
"keyboard.return.google" = "Google";
|
||||
"keyboard.return.yahoo" = "Yahoo";
|
||||
"keyboard.return.emergencyCall" = "紧急电话";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "支持本地转写";
|
||||
@@ -299,6 +307,7 @@
|
||||
"keyboard.ai.skill.translate" = "翻译";
|
||||
"keyboard.ai.skill.extractTodos" = "待办";
|
||||
"keyboard.ai.skill.extractEvents" = "日程";
|
||||
"keyboard.ai.skill.saveToNotes" = "备忘录";
|
||||
"keyboard.ai.skill.navigate" = "导航";
|
||||
"keyboard.ai.skill.previewPolish" = "润色";
|
||||
"keyboard.ai.skill.previewIdeas" = "点子";
|
||||
@@ -306,10 +315,13 @@
|
||||
"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
|
||||
"keyboard.ai.skill.noEvents" = "剪贴板内容没有日期或时间";
|
||||
"keyboard.ai.skill.noAddress" = "剪贴板内容没有可导航的地址";
|
||||
"keyboard.ai.skill.noNote" = "剪贴板没有可保存的内容";
|
||||
"keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容";
|
||||
"keyboard.ai.skill.extracting" = "正在提取待办…";
|
||||
"keyboard.ai.skill.extractingEvents" = "正在提取日程…";
|
||||
"keyboard.ai.skill.extractingAddress" = "正在识别地址…";
|
||||
"keyboard.ai.skill.namingNote" = "正在生成标题…";
|
||||
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
|
||||
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
|
||||
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
|
||||
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
|
||||
|
||||
@@ -300,4 +300,81 @@ final class EnglishTypingTests: XCTestCase {
|
||||
XCTAssertEqual(undone.deleteCount, spaced.text.count)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPeriodShortcutReplacesDoubleSpace() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
|
||||
for character in ["h", "e", "l", "l", "o"] {
|
||||
apply(typing, typing.handleKey(character))
|
||||
}
|
||||
let first = typing.handleSpace()
|
||||
XCTAssertTrue(first.text.hasSuffix(" "), "first Space should insert a space")
|
||||
apply(typing, first)
|
||||
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(second, .replace(deleteCount: 1, with: ". "))
|
||||
apply(typing, second)
|
||||
XCTAssertTrue(typing.shiftActive, "period+space must arm sentence Shift")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPeriodShortcutDoesNotFireAfterAnotherLetter() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
|
||||
apply(typing, typing.handleKey("h"))
|
||||
apply(typing, typing.handleSpace())
|
||||
apply(typing, typing.handleKey("i"))
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(second, .insert(" "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPeriodShortcutDoesNotFireAfterSentenceTerminator() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.precedingTextProvider = { "Hello." }
|
||||
typing.enterTypingMode()
|
||||
typing.syncAutocapitalization()
|
||||
|
||||
apply(typing, typing.handleSpace())
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(second, .insert(" "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testChineseSpaceDoesNotUsePeriodShortcut() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.chinese)
|
||||
let first = typing.handleSpace()
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(first.text, " ")
|
||||
XCTAssertEqual(second.text, " ")
|
||||
XCTAssertEqual(second.deleteCount, 0)
|
||||
}
|
||||
|
||||
func testPeriodShortcutPredicate() {
|
||||
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello "))
|
||||
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "你 "))
|
||||
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "v2 "))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello. "))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello "))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello"))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: " "))
|
||||
XCTAssertTrue(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello"))
|
||||
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello."))
|
||||
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
|
||||
typing.syncAutocapitalization(
|
||||
accountingForInsert: output.text,
|
||||
deleteCount: output.deleteCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,16 @@ final class KeyboardStateTests: XCTestCase {
|
||||
XCTAssertNil(state.clipboardSuggestionText)
|
||||
XCTAssertEqual(state.clipboardOverlay, .none)
|
||||
}
|
||||
|
||||
func testReturnKeyRoleActionFillAndTitles() {
|
||||
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.search.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.done.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.next.usesActionFill)
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.search.titleKey, "keyboard.return.search")
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.next.titleKey, "keyboard.return.next")
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.done.titleKey, "common.done")
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.go.titleKey, "keyboard.return.go")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// TypingTouchTrackerTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
//
|
||||
// Multi-finger overlap: press order commits, Shift hold + type, slide reselect.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class TypingTouchTrackerTests: XCTestCase {
|
||||
private final class Token {}
|
||||
|
||||
private func letter(_ id: String) -> TypingKeyHitTarget {
|
||||
TypingKeyHitTarget(
|
||||
id: id,
|
||||
label: id,
|
||||
visualFrame: .zero,
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
}
|
||||
|
||||
private func shiftKey() -> TypingKeyHitTarget {
|
||||
TypingKeyHitTarget(
|
||||
id: "shift",
|
||||
label: "⇧",
|
||||
visualFrame: .zero,
|
||||
behavior: .shiftHold
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteKey() -> TypingKeyHitTarget {
|
||||
TypingKeyHitTarget(
|
||||
id: "delete",
|
||||
label: "⌫",
|
||||
visualFrame: .zero,
|
||||
behavior: .deleteRepeat
|
||||
)
|
||||
}
|
||||
|
||||
func testSecondFingerDownCommitsFirstInPressOrder() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let a = Token()
|
||||
let b = Token()
|
||||
let keyA = letter("a")
|
||||
let keyB = letter("b")
|
||||
|
||||
let downA = tracker.began(id: ObjectIdentifier(a), key: keyA)
|
||||
XCTAssertTrue(downA.commits.isEmpty)
|
||||
XCTAssertEqual(tracker.highlightedKeyIDs, ["a"])
|
||||
|
||||
let downB = tracker.began(id: ObjectIdentifier(b), key: keyB)
|
||||
XCTAssertEqual(downB.commits.map(\.id), ["a"])
|
||||
XCTAssertEqual(tracker.highlightedKeyIDs, ["b"])
|
||||
|
||||
let upA = tracker.ended(id: ObjectIdentifier(a), key: keyA)
|
||||
XCTAssertTrue(upA.commits.isEmpty)
|
||||
|
||||
let upB = tracker.ended(id: ObjectIdentifier(b), key: keyB)
|
||||
XCTAssertEqual(upB.commits.map(\.id), ["b"])
|
||||
XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty)
|
||||
}
|
||||
|
||||
func testShiftHoldWithOtherFingerDoesNotCommitShift() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let shiftFinger = Token()
|
||||
let letterFinger = Token()
|
||||
let shift = shiftKey()
|
||||
let keyA = letter("a")
|
||||
|
||||
let downShift = tracker.began(id: ObjectIdentifier(shiftFinger), key: shift)
|
||||
XCTAssertTrue(downShift.beginShift)
|
||||
XCTAssertTrue(downShift.commits.isEmpty)
|
||||
|
||||
let downA = tracker.began(id: ObjectIdentifier(letterFinger), key: keyA)
|
||||
XCTAssertTrue(downA.commits.isEmpty)
|
||||
XCTAssertFalse(downA.beginShift)
|
||||
|
||||
let upA = tracker.ended(id: ObjectIdentifier(letterFinger), key: keyA)
|
||||
XCTAssertEqual(upA.commits.map(\.id), ["a"])
|
||||
XCTAssertFalse(upA.endShift)
|
||||
|
||||
let upShift = tracker.ended(id: ObjectIdentifier(shiftFinger), key: shift)
|
||||
XCTAssertTrue(upShift.endShift)
|
||||
XCTAssertTrue(upShift.commits.isEmpty)
|
||||
}
|
||||
|
||||
func testSlideReselectsWithoutCommittingPreviousKey() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let finger = Token()
|
||||
let keyA = letter("a")
|
||||
let keyS = letter("s")
|
||||
|
||||
_ = tracker.began(id: ObjectIdentifier(finger), key: keyA)
|
||||
let moved = tracker.moved(id: ObjectIdentifier(finger), key: keyS)
|
||||
XCTAssertTrue(moved.commits.isEmpty)
|
||||
XCTAssertEqual(moved.playFeedback?.id, "s")
|
||||
XCTAssertEqual(tracker.highlightedKeyIDs, ["s"])
|
||||
|
||||
let ended = tracker.ended(id: ObjectIdentifier(finger), key: keyS)
|
||||
XCTAssertEqual(ended.commits.map(\.id), ["s"])
|
||||
}
|
||||
|
||||
func testLiftOutsideCancelsPendingKey() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let finger = Token()
|
||||
_ = tracker.began(id: ObjectIdentifier(finger), key: letter("a"))
|
||||
let ended = tracker.ended(id: ObjectIdentifier(finger), key: nil)
|
||||
XCTAssertTrue(ended.commits.isEmpty)
|
||||
}
|
||||
|
||||
func testCancelDoesNotCommit() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let finger = Token()
|
||||
_ = tracker.began(id: ObjectIdentifier(finger), key: letter("a"))
|
||||
let cancelled = tracker.cancelled(id: ObjectIdentifier(finger))
|
||||
XCTAssertTrue(cancelled.commits.isEmpty)
|
||||
XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty)
|
||||
}
|
||||
|
||||
func testLetterWhileDeletingStopsRepeat() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let delFinger = Token()
|
||||
let letterFinger = Token()
|
||||
|
||||
let downDelete = tracker.began(id: ObjectIdentifier(delFinger), key: deleteKey())
|
||||
XCTAssertTrue(downDelete.deleteFire)
|
||||
XCTAssertTrue(downDelete.startDeleteRepeat)
|
||||
|
||||
let downLetter = tracker.began(id: ObjectIdentifier(letterFinger), key: letter("a"))
|
||||
XCTAssertTrue(downLetter.stopDeleteRepeat)
|
||||
XCTAssertFalse(downLetter.deleteFire)
|
||||
XCTAssertTrue(downLetter.commits.isEmpty)
|
||||
}
|
||||
|
||||
func testSecondFingerOnSameKeyIsIgnored() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let first = Token()
|
||||
let second = Token()
|
||||
let keyA = letter("a")
|
||||
|
||||
_ = tracker.began(id: ObjectIdentifier(first), key: keyA)
|
||||
let duplicate = tracker.began(id: ObjectIdentifier(second), key: keyA)
|
||||
XCTAssertTrue(duplicate.commits.isEmpty)
|
||||
XCTAssertNil(duplicate.playFeedback)
|
||||
|
||||
let upFirst = tracker.ended(id: ObjectIdentifier(first), key: keyA)
|
||||
XCTAssertEqual(upFirst.commits.map(\.id), ["a"])
|
||||
let upSecond = tracker.ended(id: ObjectIdentifier(second), key: keyA)
|
||||
XCTAssertTrue(upSecond.commits.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,14 @@ enum MacSection: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
/// Outline when idle, fill when selected — except Home, which stays `house`.
|
||||
func systemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .dashboard: return "house"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .dictionary: return "character.book.closed"
|
||||
case .styles: return "text.badge.star"
|
||||
case .settings: return "gearshape"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ private struct MacSidebarRow: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(section.title(language: language), systemImage: section.systemImage)
|
||||
Label(section.title(language: language), systemImage: section.systemImage(selected: isSelected))
|
||||
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -25,6 +25,8 @@ public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
|
||||
|
||||
public enum AIAgentShortcutRun {
|
||||
public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
|
||||
/// Last skill-handoff lines, readable from the App Group plist on device.
|
||||
public static let recentTracesKey = "diag.skills.recent.v1"
|
||||
/// Drop payloads older than this; a leftover write must not fire later.
|
||||
public static let payloadTTL: TimeInterval = 60
|
||||
|
||||
@@ -65,6 +67,19 @@ public enum AIAgentShortcutRun {
|
||||
/// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
|
||||
public static func trace(_ message: String) {
|
||||
OSGDiag.log(message, category: "skills")
|
||||
persistTrace(message)
|
||||
}
|
||||
|
||||
/// Keep a short ring so we can copy traces off the phone without root `log collect`.
|
||||
private static func persistTrace(_ message: String) {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
var lines = defaults.stringArray(forKey: recentTracesKey) ?? []
|
||||
let stamp = ISO8601DateFormatter().string(from: Date())
|
||||
lines.append("\(stamp) \(message)")
|
||||
if lines.count > 60 {
|
||||
lines = Array(lines.suffix(60))
|
||||
}
|
||||
defaults.set(lines, forKey: recentTracesKey)
|
||||
}
|
||||
|
||||
/// Single-line preview so Console keeps the format (`\\n` for newlines).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
||||
// Settings / the Skills tab can persist a subset or permutation without
|
||||
// changing the view. Transform skills insert into the current field;
|
||||
// export skills hand off to a companion Shortcut after the model runs.
|
||||
// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -39,7 +39,9 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
/// Built-in skills are always false. Custom skills default off.
|
||||
public let thinkingEnabled: Bool
|
||||
|
||||
public var requiresShortcut: Bool { kind == .export }
|
||||
/// Reminders, Calendar, and Notes exports need a companion Shortcut.
|
||||
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
|
||||
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
|
||||
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||
|
||||
public init(
|
||||
@@ -80,22 +82,18 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static let summarizeID = "summarize"
|
||||
public static let translateID = "translate"
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSG · 提取待办"
|
||||
public static let extractTodosShortcutICloudURL = URL(
|
||||
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
|
||||
)!
|
||||
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||
|
||||
public static let extractEventsID = "extractEvents"
|
||||
public static let extractEventsShortcutName = "OSG · 提取日程"
|
||||
public static let extractEventsShortcutICloudURL = URL(
|
||||
string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
|
||||
)!
|
||||
public static let extractEventsShortcutName = "OSGExtractEvents"
|
||||
public static let extractEventsResourceName = "OSGExtractEvents"
|
||||
|
||||
public static let saveToNotesID = "saveToNotes"
|
||||
public static let saveToNotesShortcutName = "OSGSaveToNotes"
|
||||
public static let saveToNotesResourceName = "OSGSaveToNotes"
|
||||
|
||||
public static let navigateID = "navigate"
|
||||
public static let navigateShortcutName = "OSG · 导航"
|
||||
public static let navigateResourceName = "OSGNavigate"
|
||||
|
||||
/// Full built-in catalog, in a stable display order for the Skills tab.
|
||||
public static let catalog: [AIClipboardSkill] = [
|
||||
@@ -135,7 +133,6 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: extractTodosShortcutName,
|
||||
shortcutICloudURL: extractTodosShortcutICloudURL,
|
||||
shortcutResourceName: extractTodosResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
@@ -147,9 +144,19 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: extractEventsShortcutName,
|
||||
shortcutICloudURL: extractEventsShortcutICloudURL,
|
||||
shortcutResourceName: extractEventsResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: saveToNotesID,
|
||||
systemImage: "note.text",
|
||||
titleKey: "keyboard.ai.skill.saveToNotes",
|
||||
cardTitleKey: "skills.saveToNotes.name",
|
||||
descriptionKey: "skills.saveToNotes.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: saveToNotesShortcutName,
|
||||
shortcutResourceName: saveToNotesResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: navigateID,
|
||||
systemImage: "arrow.triangle.turn.up.right.diamond.fill",
|
||||
@@ -157,9 +164,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
cardTitleKey: "skills.navigate.name",
|
||||
descriptionKey: "skills.navigate.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: navigateShortcutName,
|
||||
shortcutResourceName: navigateResourceName
|
||||
isDefault: false
|
||||
),
|
||||
]
|
||||
|
||||
@@ -263,6 +268,8 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
"""
|
||||
case extractEventsID:
|
||||
return eventInstruction(zh: zh, now: now)
|
||||
case saveToNotesID:
|
||||
return noteInstruction(zh: zh, now: now)
|
||||
case navigateID:
|
||||
return navigateInstruction(zh: zh)
|
||||
default:
|
||||
@@ -272,6 +279,26 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Title only. The original clipboard is the note body; do not ask the
|
||||
/// model to rewrite it.
|
||||
private static func noteInstruction(zh: Bool, now: Date) -> String {
|
||||
let clock = clockContext(now: now, zh: zh)
|
||||
if zh {
|
||||
return """
|
||||
\(clock)
|
||||
请根据剪贴板正文写一个简短备忘录标题。只要一行标题,不要输出正文,不要编号、不要引号、不要解释。标题中不要出现换行或 |。最多 40 个字。
|
||||
标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。
|
||||
即使原文很短也要给一个标题。不要输出 NONE。
|
||||
"""
|
||||
}
|
||||
return """
|
||||
\(clock)
|
||||
Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters.
|
||||
The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body.
|
||||
Always return a title, even when the clipboard is short. Do not output NONE.
|
||||
"""
|
||||
}
|
||||
|
||||
private static func navigateInstruction(zh: Bool) -> String {
|
||||
if zh {
|
||||
return """
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Line-oriented Shortcut input for user-created export skills. Built-in
|
||||
// extract-todos / extract-events keep their dedicated parsers.
|
||||
// extract-todos / extract-events / save-to-notes keep their dedicated parsers.
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu →
|
||||
// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that
|
||||
// check and passes the resulting URL to the companion Shortcut.
|
||||
// Apple Maps. The host calls `canOpenURL` and opens the URL itself.
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// AINoteExport.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds one Notes payload: generated title + original clipboard body,
|
||||
// joined by `fieldSeparator` so the companion Shortcut can combine them
|
||||
// as first-line title + body. Fail open: a missing or unusable model title
|
||||
// falls back to a dated snippet so the paste still lands in Notes.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AINoteExport: Sendable {
|
||||
public static let maximumTitleLength = 40
|
||||
public static let maximumSnippetLength = 24
|
||||
/// Split token for the companion Shortcut: title, then original body.
|
||||
/// Newlines stay inside the body, so do not join with `\n`.
|
||||
/// Avoid `<>` — `shortcuts://…&text=` treats angle brackets like tags and
|
||||
/// drops the payload (todos/events work because they only use `|` / newlines).
|
||||
public static let fieldSeparator = "||OSG_NOTE||"
|
||||
|
||||
private static let emptyTokens: Set<String> = [
|
||||
"none", "no", "n/a", "na", "nil", "null",
|
||||
"无", "没有", "没有标题", "无标题",
|
||||
"no title", "no note", "no notes",
|
||||
]
|
||||
|
||||
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty → do not run it.
|
||||
public static func items(
|
||||
from answer: String,
|
||||
sourceClipboard: String?,
|
||||
now: Date = Date(),
|
||||
locale: String = "zh",
|
||||
calendar: Calendar = .current
|
||||
) -> [String] {
|
||||
let body = sourceClipboard?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !body.isEmpty else { return [] }
|
||||
let title = resolvedTitle(
|
||||
from: answer,
|
||||
body: body,
|
||||
now: now,
|
||||
locale: locale,
|
||||
calendar: calendar
|
||||
)
|
||||
return ["\(title)\(fieldSeparator)\(body)"]
|
||||
}
|
||||
|
||||
// MARK: - Title
|
||||
|
||||
private static func resolvedTitle(
|
||||
from answer: String,
|
||||
body: String,
|
||||
now: Date,
|
||||
locale: String,
|
||||
calendar: Calendar
|
||||
) -> String {
|
||||
if let title = parsedTitle(answer),
|
||||
!isWholeClipboardEcho(title, source: body) {
|
||||
return truncate(title, maximumTitleLength)
|
||||
}
|
||||
return fallbackTitle(body: body, now: now, locale: locale, calendar: calendar)
|
||||
}
|
||||
|
||||
private static func parsedTitle(_ raw: String) -> String? {
|
||||
let first = raw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
.first ?? ""
|
||||
var title = stripBullet(first)
|
||||
title = stripWrappingQuotes(title)
|
||||
if let pipe = title.firstIndex(of: "|") {
|
||||
title = String(title[..<pipe])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
|
||||
return title
|
||||
}
|
||||
|
||||
private static func fallbackTitle(
|
||||
body: String,
|
||||
now: Date,
|
||||
locale: String,
|
||||
calendar: Calendar
|
||||
) -> String {
|
||||
let stamp = dateStamp(now, locale: locale, calendar: calendar)
|
||||
let snippet = truncate(firstLine(body), maximumSnippetLength)
|
||||
if snippet.isEmpty { return stamp }
|
||||
return "\(stamp) · \(snippet)"
|
||||
}
|
||||
|
||||
private static func dateStamp(_ now: Date, locale: String, calendar: Calendar) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.locale = Locale(identifier: locale == "zh" ? "zh_CN" : "en_US_POSIX")
|
||||
formatter.dateFormat = locale == "zh" ? "M月d日" : "d MMM"
|
||||
return formatter.string(from: now)
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
private static func firstLine(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
.first?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private static func truncate(_ text: String, _ limit: Int) -> String {
|
||||
guard text.count > limit else { return text }
|
||||
return String(text.prefix(limit))
|
||||
}
|
||||
|
||||
private static func isEmptyToken(_ text: String) -> Bool {
|
||||
let folded = text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!!"))
|
||||
.lowercased()
|
||||
return emptyTokens.contains(folded)
|
||||
}
|
||||
|
||||
private static func stripBullet(_ line: String) -> String {
|
||||
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let prefixes = ["- ", "* ", "• ", "、"]
|
||||
for prefix in prefixes where text.hasPrefix(prefix) {
|
||||
text = String(text.dropFirst(prefix.count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
|
||||
text = String(text[dotted.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func stripWrappingQuotes(_ line: String) -> String {
|
||||
let pairs: [(Character, Character)] = [
|
||||
("\"", "\""),
|
||||
("“", "”"),
|
||||
("「", "」"),
|
||||
("『", "』"),
|
||||
("'", "'"),
|
||||
("‘", "’"),
|
||||
]
|
||||
var text = line
|
||||
for (open, close) in pairs where text.count >= 2 {
|
||||
if text.first == open, text.last == close {
|
||||
text = String(text.dropFirst().dropLast())
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// A title that is essentially the whole clipboard is not a title.
|
||||
private static func isWholeClipboardEcho(_ title: String, source: String) -> Bool {
|
||||
guard source.count > 80, title.count > 80 else { return false }
|
||||
let a = collapse(title)
|
||||
let b = collapse(source)
|
||||
guard !a.isEmpty, !b.isEmpty else { return false }
|
||||
if a == b { return true }
|
||||
return a.contains(b) || b.contains(a)
|
||||
}
|
||||
|
||||
private static func collapse(_ text: String) -> String {
|
||||
text.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,9 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
|
||||
/// Export skill currently waiting on the LLM. Nil for transform skills.
|
||||
@Published public var pendingClipboardSkillID: String?
|
||||
/// Clipboard captured when that export skill was tapped, so the body
|
||||
/// still exists after the 30-second hint window closes.
|
||||
public var pendingClipboardSkillSource: String?
|
||||
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
|
||||
@Published public var skillTipText: String?
|
||||
/// Host field is a password / secure entry — never read pasteboard.
|
||||
@@ -219,13 +222,38 @@ public final class KeyboardState: ObservableObject {
|
||||
public enum ReturnKeyRole: Equatable {
|
||||
case newline
|
||||
case send
|
||||
case go
|
||||
case search
|
||||
case join
|
||||
case done
|
||||
case next
|
||||
case `continue`
|
||||
case route
|
||||
case google
|
||||
case yahoo
|
||||
case emergencyCall
|
||||
|
||||
public var titleKey: String {
|
||||
switch self {
|
||||
case .newline: return "common.newline"
|
||||
case .send: return "common.send"
|
||||
case .send: return "common.send"
|
||||
case .go: return "keyboard.return.go"
|
||||
case .search: return "keyboard.return.search"
|
||||
case .join: return "keyboard.return.join"
|
||||
case .done: return "common.done"
|
||||
case .next: return "keyboard.return.next"
|
||||
case .continue: return "common.continue"
|
||||
case .route: return "keyboard.return.route"
|
||||
case .google: return "keyboard.return.google"
|
||||
case .yahoo: return "keyboard.return.yahoo"
|
||||
case .emergencyCall: return "keyboard.return.emergencyCall"
|
||||
}
|
||||
}
|
||||
|
||||
/// Green action chrome (system uses blue for Go / Search / Send / Done).
|
||||
public var usesActionFill: Bool {
|
||||
self != .newline
|
||||
}
|
||||
}
|
||||
|
||||
// Action hooks — injected by the view controller at install time.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// PeriodShortcut.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// iOS "." Shortcut: a second Space shortly after a Space that follows a
|
||||
// word character becomes ". " and arms sentence Shift.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PeriodShortcut: Sendable {
|
||||
/// Window for the second Space tap. Slow consecutive spaces stay spaces.
|
||||
public static let doubleTapInterval: TimeInterval = 0.45
|
||||
|
||||
/// Whether `precedingText` (already including the first space) can take
|
||||
/// the shortcut: `…X ` where X is a letter or number, not a terminator.
|
||||
public static func shouldReplacePreviousSpace(precedingText: String) -> Bool {
|
||||
guard precedingText.last == " " else { return false }
|
||||
guard let previous = precedingText.dropLast().last else { return false }
|
||||
if previous.isWhitespace || previous.isNewline { return false }
|
||||
return previous.isLetter || previous.isNumber
|
||||
}
|
||||
|
||||
/// After inserting a space, arm only when that space followed a word char.
|
||||
public static func shouldArm(afterSpaceFollowing precedingBeforeSpace: String) -> Bool {
|
||||
guard let last = precedingBeforeSpace.last else { return false }
|
||||
if last.isWhitespace || last.isNewline { return false }
|
||||
return last.isLetter || last.isNumber
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
|
||||
private var precedingShadow = ""
|
||||
private static let precedingShadowLimit = 400
|
||||
/// iOS "." Shortcut: second Space shortly after a Space that followed a word.
|
||||
private var periodShortcutArmed = false
|
||||
private var lastSpaceAt: Date?
|
||||
|
||||
public init(
|
||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||
@@ -166,6 +169,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
page = .letters
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
clearPeriodShortcut()
|
||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||
EnglishLexicon.shared.unload()
|
||||
englishStorage = nil
|
||||
@@ -202,6 +206,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
engine.setLanguage(newLanguage)
|
||||
page = .letters
|
||||
isCandidatePanelExpanded = false
|
||||
clearPeriodShortcut()
|
||||
if newLanguage == .english {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
refreshPersonalTerms()
|
||||
@@ -235,6 +240,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
resetShiftState()
|
||||
clearPeriodShortcut()
|
||||
if page == .letters {
|
||||
syncAutocapitalization()
|
||||
}
|
||||
@@ -264,6 +270,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
/// Handle a visible key label.
|
||||
public func handleKey(_ label: String) -> TypingOutput {
|
||||
clearPeriodShortcut()
|
||||
switch label {
|
||||
case "⇧":
|
||||
// Tests / non-gesture callers: same as a completed Shift tap.
|
||||
@@ -317,8 +324,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
public func handleSpace() -> TypingOutput {
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: " ")
|
||||
return handleEnglishSpace()
|
||||
}
|
||||
clearPeriodShortcut()
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
@@ -326,6 +334,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func handleReturn() -> TypingOutput {
|
||||
clearPeriodShortcut()
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: "\n")
|
||||
}
|
||||
@@ -336,6 +345,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> TypingOutput {
|
||||
clearPeriodShortcut()
|
||||
if language == .english {
|
||||
return selectEnglishCandidate(at: index)
|
||||
}
|
||||
@@ -352,6 +362,38 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
// MARK: - English
|
||||
|
||||
private func handleEnglishSpace() -> TypingOutput {
|
||||
let preceding = precedingTextForShortcut()
|
||||
if periodShortcutArmed,
|
||||
let stamped = lastSpaceAt,
|
||||
Date().timeIntervalSince(stamped) <= PeriodShortcut.doubleTapInterval,
|
||||
PeriodShortcut.shouldReplacePreviousSpace(precedingText: preceding) {
|
||||
clearPeriodShortcut()
|
||||
let wordOut = commitEnglishWord(suffix: "")
|
||||
let deleteCount = wordOut.deleteCount + 1
|
||||
return TypingOutput(deleteCount: deleteCount, text: wordOut.text + ". ")
|
||||
}
|
||||
|
||||
let output = commitEnglishWord(suffix: " ")
|
||||
if PeriodShortcut.shouldArm(afterSpaceFollowing: preceding) {
|
||||
periodShortcutArmed = true
|
||||
lastSpaceAt = Date()
|
||||
} else {
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func precedingTextForShortcut() -> String {
|
||||
if !precedingShadow.isEmpty { return precedingShadow }
|
||||
return precedingTextProvider?() ?? ""
|
||||
}
|
||||
|
||||
private func clearPeriodShortcut() {
|
||||
periodShortcutArmed = false
|
||||
lastSpaceAt = nil
|
||||
}
|
||||
|
||||
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
|
||||
pendingAutocorrection = nil
|
||||
if ch.isLetter {
|
||||
@@ -546,6 +588,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// briefly reports the immediately preceding edit (common in Notes).
|
||||
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
|
||||
guard language == .english else { return }
|
||||
if caretMoved {
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
guard suggestionsEnabled else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
composition = .empty
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// TypingTouchTracker.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Multi-finger typing contract (system-keyboard overlap):
|
||||
// each finger is independent; a new key-down commits any other pending
|
||||
// character/space/return so press order, not release order, wins.
|
||||
// Shift can be held with one finger while another types.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Per-step side effects for the UIKit touch pad to apply.
|
||||
public struct TypingTouchEffects: Equatable, Sendable {
|
||||
public var commits: [TypingKeyHitTarget] = []
|
||||
public var playFeedback: TypingKeyHitTarget?
|
||||
public var deleteFire = false
|
||||
public var startDeleteRepeat = false
|
||||
public var stopDeleteRepeat = false
|
||||
public var beginShift = false
|
||||
public var endShift = false
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// Pure multi-touch state machine. IDs are `ObjectIdentifier` of `UITouch`
|
||||
/// in the extension, or any unique object in tests.
|
||||
public final class TypingTouchTracker {
|
||||
private struct Finger {
|
||||
let id: ObjectIdentifier
|
||||
var key: TypingKeyHitTarget?
|
||||
var committed: Bool
|
||||
var ownsShift: Bool
|
||||
var ownsDeleteRepeat: Bool
|
||||
let order: UInt64
|
||||
}
|
||||
|
||||
private var fingers: [ObjectIdentifier: Finger] = [:]
|
||||
private var nextOrder: UInt64 = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public var highlightedKeyIDs: Set<String> {
|
||||
Set(
|
||||
fingers.values.compactMap { finger in
|
||||
guard !finger.committed else { return nil }
|
||||
return finger.key?.id
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func began(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
|
||||
var effects = TypingTouchEffects()
|
||||
|
||||
if let key, hasUncommittedFinger(on: key.id) {
|
||||
return effects
|
||||
}
|
||||
|
||||
if key != nil {
|
||||
commitPendingCharacterKeys(into: &effects)
|
||||
stopForeignDeleteRepeats(into: &effects)
|
||||
}
|
||||
|
||||
var finger = Finger(
|
||||
id: id,
|
||||
key: key,
|
||||
committed: false,
|
||||
ownsShift: false,
|
||||
ownsDeleteRepeat: false,
|
||||
order: nextOrder
|
||||
)
|
||||
nextOrder += 1
|
||||
|
||||
if let key {
|
||||
effects.playFeedback = key
|
||||
activate(key, on: &finger, effects: &effects)
|
||||
}
|
||||
fingers[id] = finger
|
||||
return effects
|
||||
}
|
||||
|
||||
public func moved(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
|
||||
guard var finger = fingers[id], !finger.committed else {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
if finger.key?.id == key?.id {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
|
||||
var effects = TypingTouchEffects()
|
||||
if finger.ownsDeleteRepeat {
|
||||
effects.stopDeleteRepeat = true
|
||||
finger.ownsDeleteRepeat = false
|
||||
}
|
||||
|
||||
finger.key = key
|
||||
if let key {
|
||||
effects.playFeedback = key
|
||||
activate(key, on: &finger, effects: &effects)
|
||||
}
|
||||
fingers[id] = finger
|
||||
return effects
|
||||
}
|
||||
|
||||
public func ended(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
|
||||
guard let finger = fingers.removeValue(forKey: id) else {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
return finish(finger, hit: key, commitIfNeeded: true)
|
||||
}
|
||||
|
||||
public func cancelled(id: ObjectIdentifier) -> TypingTouchEffects {
|
||||
guard let finger = fingers.removeValue(forKey: id) else {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
return finish(finger, hit: nil, commitIfNeeded: false)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func activate(
|
||||
_ key: TypingKeyHitTarget,
|
||||
on finger: inout Finger,
|
||||
effects: inout TypingTouchEffects
|
||||
) {
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
break
|
||||
case .deleteRepeat:
|
||||
effects.deleteFire = true
|
||||
effects.startDeleteRepeat = true
|
||||
finger.ownsDeleteRepeat = true
|
||||
case .shiftHold:
|
||||
if !finger.ownsShift, !fingers.values.contains(where: { $0.ownsShift }) {
|
||||
effects.beginShift = true
|
||||
finger.ownsShift = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Press order = typing order: flush other uncommitted character keys.
|
||||
private func commitPendingCharacterKeys(into effects: inout TypingTouchEffects) {
|
||||
let pending = fingers.values
|
||||
.filter { !$0.committed && $0.key?.behavior == .commitOnRelease }
|
||||
.sorted { $0.order < $1.order }
|
||||
for finger in pending {
|
||||
if let key = finger.key {
|
||||
effects.commits.append(key)
|
||||
}
|
||||
fingers[finger.id]?.committed = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Holding delete + tapping a letter must stop the repeat.
|
||||
private func stopForeignDeleteRepeats(into effects: inout TypingTouchEffects) {
|
||||
for (id, finger) in fingers where finger.ownsDeleteRepeat {
|
||||
effects.stopDeleteRepeat = true
|
||||
fingers[id]?.ownsDeleteRepeat = false
|
||||
}
|
||||
}
|
||||
|
||||
private func hasUncommittedFinger(on keyID: String) -> Bool {
|
||||
fingers.values.contains { !$0.committed && $0.key?.id == keyID }
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ finger: Finger,
|
||||
hit: TypingKeyHitTarget?,
|
||||
commitIfNeeded: Bool
|
||||
) -> TypingTouchEffects {
|
||||
var effects = TypingTouchEffects()
|
||||
if finger.ownsDeleteRepeat {
|
||||
effects.stopDeleteRepeat = true
|
||||
}
|
||||
if finger.ownsShift {
|
||||
effects.endShift = true
|
||||
}
|
||||
if commitIfNeeded, !finger.committed, let hit, hit.behavior == .commitOnRelease {
|
||||
effects.commits.append(hit)
|
||||
}
|
||||
return effects
|
||||
}
|
||||
}
|
||||
@@ -163,12 +163,24 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertNil(AIAgentShortcutRun.decode(data))
|
||||
}
|
||||
|
||||
func testShortcutsRunURLEncodesNameAndText() {
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSG · 提取待办", text: "买牛奶\n回邮件")
|
||||
func testShortcutsRunURLPreservesNotesFieldSeparator() {
|
||||
let text = "周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSGSaveToNotes", text: text)
|
||||
let raw = url?.absoluteString ?? ""
|
||||
XCTAssertFalse(raw.contains("<"), "angle brackets in the URL get stripped by Shortcuts")
|
||||
XCTAssertFalse(raw.contains(">"))
|
||||
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
XCTAssertEqual(items.first { $0.name == "text" }?.value, text)
|
||||
XCTAssertTrue(text.contains("||OSG_NOTE||"))
|
||||
}
|
||||
|
||||
func testShortcutsRunURLIncludesNameAndText() {
|
||||
let shortcutName = AIClipboardSkillCatalog.extractTodosShortcutName
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: shortcutName, text: "买牛奶\n回邮件")
|
||||
XCTAssertEqual(url?.scheme, "shortcuts")
|
||||
XCTAssertEqual(url?.host, "run-shortcut")
|
||||
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSG · 提取待办")
|
||||
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGExtractTodos")
|
||||
XCTAssertEqual(items.first { $0.name == "input" }?.value, "text")
|
||||
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件")
|
||||
XCTAssertNil(items.first { $0.name == "x-success" })
|
||||
@@ -176,7 +188,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
func testXCallbackRunURLUsesCallbackHost() {
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(
|
||||
name: "OSG · 提取待办",
|
||||
name: AIClipboardSkillCatalog.extractTodosShortcutName,
|
||||
text: "买牛奶",
|
||||
xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
|
||||
xError: "osgkeyboard://skill/shortcut-result?status=error",
|
||||
@@ -197,51 +209,67 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件")
|
||||
}
|
||||
|
||||
func testExtractTodosUsesICloudShareLink() {
|
||||
func testExtractTodosUsesBundledShortcut() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID)
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutICloudURL,
|
||||
AIClipboardSkillCatalog.extractTodosShortcutICloudURL
|
||||
)
|
||||
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
||||
XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办")
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
|
||||
}
|
||||
|
||||
func testExtractEventsUsesICloudShareLink() {
|
||||
func testExtractEventsUsesBundledShortcut() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutICloudURL,
|
||||
AIClipboardSkillCatalog.extractEventsShortcutICloudURL
|
||||
)
|
||||
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
||||
XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程")
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents")
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutResourceName,
|
||||
"OSGExtractEvents"
|
||||
)
|
||||
XCTAssertEqual(skill?.systemImage, "calendar")
|
||||
XCTAssertFalse(skill?.isDefault ?? true)
|
||||
XCTAssertEqual(
|
||||
AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!),
|
||||
"1f4afcf7ee22400cbf84e319d969aadf"
|
||||
)
|
||||
}
|
||||
|
||||
func testNavigateUsesBundledShortcut() {
|
||||
func testNavigateDoesNotRequireShortcut() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID)
|
||||
XCTAssertNil(skill?.shortcutName)
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSG · 导航")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate")
|
||||
XCTAssertNil(skill?.shortcutResourceName)
|
||||
XCTAssertEqual(
|
||||
skill?.systemImage,
|
||||
"arrow.triangle.turn.up.right.diamond.fill"
|
||||
)
|
||||
XCTAssertFalse(skill?.isDefault ?? true)
|
||||
XCTAssertEqual(skill?.kind, .export)
|
||||
XCTAssertFalse(skill?.requiresShortcut ?? true)
|
||||
}
|
||||
|
||||
func testNavigateEnablesWithoutShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
XCTAssertEqual(
|
||||
store.enable(AIClipboardSkillCatalog.navigateID),
|
||||
.enabled
|
||||
)
|
||||
XCTAssertTrue(store.layout.isEnabled(AIClipboardSkillCatalog.navigateID))
|
||||
}
|
||||
|
||||
func testSaveToNotesUsesBundledShortcutUntilICloudShareExists() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.saveToNotesID)
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGSaveToNotes")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGSaveToNotes")
|
||||
XCTAssertEqual(skill?.systemImage, "note.text")
|
||||
XCTAssertFalse(skill?.isDefault ?? true)
|
||||
XCTAssertTrue(skill?.requiresShortcut ?? false)
|
||||
}
|
||||
|
||||
func testCannotEnableSaveToNotesBeforeShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
XCTAssertEqual(
|
||||
store.enable(AIClipboardSkillCatalog.saveToNotesID),
|
||||
.needsShortcut
|
||||
)
|
||||
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID))
|
||||
}
|
||||
|
||||
func testICloudShareLinkMapsToShortcutsInstallURL() {
|
||||
let share = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
|
||||
let url = AIAgentShortcutRun.shortcutsInstallURL(from: share)
|
||||
|
||||
@@ -265,4 +265,15 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
XCTAssertTrue(prompt.contains("NONE"))
|
||||
XCTAssertTrue(prompt.contains("起点|终点"))
|
||||
}
|
||||
|
||||
func testSaveToNotesAsksForTitleNotBody() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.saveToNotesID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("标题"))
|
||||
XCTAssertTrue(prompt.contains("不要输出正文"))
|
||||
XCTAssertTrue(prompt.contains("不要输出 NONE"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// AINoteExportTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AINoteExportTests: XCTestCase {
|
||||
private var calendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||
return calendar
|
||||
}
|
||||
|
||||
private var now: Date {
|
||||
date(2026, 8, 13, 23, 56)
|
||||
}
|
||||
|
||||
func testEmptyClipboardProducesNoItems() {
|
||||
XCTAssertEqual(items(from: "周会纪要", clipboard: nil), [])
|
||||
XCTAssertEqual(items(from: "周会纪要", clipboard: " \n "), [])
|
||||
}
|
||||
|
||||
func testUsesModelTitleAndKeepsOriginalBody() {
|
||||
XCTAssertEqual(
|
||||
items(from: "8月13日周会纪要", clipboard: "第一项\n第二项"),
|
||||
["8月13日周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"]
|
||||
)
|
||||
}
|
||||
|
||||
func testIgnoresModelBodyAfterFirstLine() {
|
||||
let answer = """
|
||||
购物清单
|
||||
这是模型改写过的正文,不应写入备忘录。
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
items(from: answer, clipboard: "买牛奶\n买鸡蛋"),
|
||||
["购物清单\(AINoteExport.fieldSeparator)买牛奶\n买鸡蛋"]
|
||||
)
|
||||
}
|
||||
|
||||
func testTakesTitleBeforePipe() {
|
||||
XCTAssertEqual(
|
||||
items(from: "会议纪要|请忽略这段模型正文", clipboard: "原文"),
|
||||
["会议纪要\(AINoteExport.fieldSeparator)原文"]
|
||||
)
|
||||
}
|
||||
|
||||
func testNONEFallsBackToDatedSnippet() {
|
||||
XCTAssertEqual(
|
||||
items(from: "NONE", clipboard: "买牛奶"),
|
||||
["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
items(from: "没有标题", clipboard: "买牛奶"),
|
||||
["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
|
||||
)
|
||||
}
|
||||
|
||||
func testEnglishFallbackUsesMonthDay() {
|
||||
XCTAssertEqual(
|
||||
items(from: "NONE", clipboard: "Buy milk", locale: "en"),
|
||||
["13 Aug · Buy milk\(AINoteExport.fieldSeparator)Buy milk"]
|
||||
)
|
||||
}
|
||||
|
||||
func testStripsQuotesAndBullets() {
|
||||
XCTAssertEqual(
|
||||
items(from: "「周会纪要」", clipboard: "纪要正文"),
|
||||
["周会纪要\(AINoteExport.fieldSeparator)纪要正文"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
items(from: "- 周会纪要", clipboard: "纪要正文"),
|
||||
["周会纪要\(AINoteExport.fieldSeparator)纪要正文"]
|
||||
)
|
||||
}
|
||||
|
||||
func testKeepsTitleAndBodyWhenTheyMatch() {
|
||||
XCTAssertEqual(
|
||||
items(from: "买牛奶", clipboard: "买牛奶"),
|
||||
["买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
|
||||
)
|
||||
}
|
||||
|
||||
func testTruncatesLongTitle() {
|
||||
let title = String(repeating: "纪", count: 50)
|
||||
let items = items(from: title, clipboard: "正文")
|
||||
XCTAssertEqual(items.count, 1)
|
||||
let fields = items[0].components(separatedBy: AINoteExport.fieldSeparator)
|
||||
XCTAssertEqual(fields.count, 2)
|
||||
XCTAssertEqual(fields[0].count, AINoteExport.maximumTitleLength)
|
||||
XCTAssertEqual(fields[1], "正文")
|
||||
}
|
||||
|
||||
func testWholeClipboardEchoFallsBack() {
|
||||
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
|
||||
let items = items(from: source, clipboard: source)
|
||||
XCTAssertEqual(items.count, 1)
|
||||
XCTAssertTrue(items[0].hasPrefix("8月13日 · "))
|
||||
XCTAssertTrue(items[0].hasSuffix("\(AINoteExport.fieldSeparator)\(source)"))
|
||||
}
|
||||
|
||||
func testPromptAsksForTitleOnly() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.saveToNotesID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
now: now
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("标题"))
|
||||
XCTAssertTrue(prompt.contains("不要输出正文"))
|
||||
XCTAssertTrue(prompt.contains("不要改写"))
|
||||
}
|
||||
|
||||
func testEnglishPromptAsksForTitleOnly() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.saveToNotesID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
now: now
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("title"))
|
||||
XCTAssertTrue(prompt.contains("do not output the body"))
|
||||
}
|
||||
|
||||
private func items(
|
||||
from answer: String,
|
||||
clipboard: String?,
|
||||
locale: String = "zh"
|
||||
) -> [String] {
|
||||
AINoteExport.items(
|
||||
from: answer,
|
||||
sourceClipboard: clipboard,
|
||||
now: now,
|
||||
locale: locale,
|
||||
calendar: calendar
|
||||
)
|
||||
}
|
||||
|
||||
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
|
||||
calendar.date(
|
||||
from: DateComponents(
|
||||
year: year,
|
||||
month: month,
|
||||
day: day,
|
||||
hour: hour,
|
||||
minute: minute
|
||||
)
|
||||
)!
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
|
||||
UNSIGNED = OUT_DIR / "OSGExtractEvents.unsigned.shortcut"
|
||||
SIGNED = OUT_DIR / "OSGExtractEvents.shortcut"
|
||||
SHORTCUT_NAME = "OSGExtractEvents"
|
||||
|
||||
OBJECT_REPLACEMENT = "\ufffc"
|
||||
|
||||
@@ -156,7 +157,7 @@ def workflow() -> dict:
|
||||
"WFWorkflowClientRelease": "26.0",
|
||||
"WFWorkflowMinimumClientVersion": 900,
|
||||
"WFWorkflowMinimumClientVersionString": "900",
|
||||
"WFWorkflowName": "OSG · 提取日程",
|
||||
"WFWorkflowName": SHORTCUT_NAME,
|
||||
"WFWorkflowHasShortcutInputVariables": True,
|
||||
"WFWorkflowHasOutputFallback": False,
|
||||
"WFWorkflowImportQuestions": [],
|
||||
|
||||
@@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
|
||||
UNSIGNED = OUT_DIR / "OSGExtractTodos.unsigned.shortcut"
|
||||
SIGNED = OUT_DIR / "OSGExtractTodos.shortcut"
|
||||
SHORTCUT_NAME = "OSGExtractTodos"
|
||||
|
||||
OBJECT_REPLACEMENT = "\ufffc"
|
||||
|
||||
@@ -77,7 +78,7 @@ def workflow() -> dict:
|
||||
"WFWorkflowClientRelease": "26.0",
|
||||
"WFWorkflowMinimumClientVersion": 900,
|
||||
"WFWorkflowMinimumClientVersionString": "900",
|
||||
"WFWorkflowName": "OSG · 提取待办",
|
||||
"WFWorkflowName": SHORTCUT_NAME,
|
||||
"WFWorkflowHasShortcutInputVariables": True,
|
||||
"WFWorkflowHasOutputFallback": False,
|
||||
"WFWorkflowImportQuestions": [],
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the companion Shortcut for the Navigate skill.
|
||||
|
||||
Writes an unsigned binary plist that `shortcuts sign` can notarize.
|
||||
|
||||
The host already picked 高德 → 百度 → Apple Maps and passes one URL.
|
||||
This recipe only materializes Shortcut Input as Text and opens it:
|
||||
|
||||
Text (Shortcut Input)
|
||||
→ Open URLs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
|
||||
UNSIGNED = OUT_DIR / "OSGNavigate.unsigned.shortcut"
|
||||
SIGNED = OUT_DIR / "OSGNavigate.shortcut"
|
||||
|
||||
OBJECT_REPLACEMENT = "\ufffc"
|
||||
|
||||
TEXT_UUID = "C1D2E3F0-1111-4E00-8A5A-FD7E1B0C396F"
|
||||
OPEN_UUID = "C1D2E3F1-2222-4F11-9A6B-0E8F2C1D4A70"
|
||||
|
||||
|
||||
def token_string(attachment_value: dict) -> dict:
|
||||
return {
|
||||
"Value": {
|
||||
"attachmentsByRange": {"{0, 1}": attachment_value},
|
||||
"string": OBJECT_REPLACEMENT,
|
||||
},
|
||||
"WFSerializationType": "WFTextTokenString",
|
||||
}
|
||||
|
||||
|
||||
def shortcut_input_text() -> dict:
|
||||
return token_string({"Type": "ExtensionInput"})
|
||||
|
||||
|
||||
def action_output_text(output_name: str, output_uuid: str) -> dict:
|
||||
return token_string(
|
||||
{
|
||||
"Aggrandizements": [],
|
||||
"OutputName": output_name,
|
||||
"OutputUUID": output_uuid,
|
||||
"Type": "ActionOutput",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def workflow() -> dict:
|
||||
return {
|
||||
"WFWorkflowClientVersion": "3600.0.4",
|
||||
"WFWorkflowClientRelease": "26.0",
|
||||
"WFWorkflowMinimumClientVersion": 900,
|
||||
"WFWorkflowMinimumClientVersionString": "900",
|
||||
"WFWorkflowName": "OSG · 导航",
|
||||
"WFWorkflowHasShortcutInputVariables": True,
|
||||
"WFWorkflowHasOutputFallback": False,
|
||||
"WFWorkflowImportQuestions": [],
|
||||
"WFWorkflowTypes": [],
|
||||
"WFWorkflowInputContentItemClasses": [
|
||||
"WFStringContentItem",
|
||||
"WFURLContentItem",
|
||||
"WFRichTextContentItem",
|
||||
],
|
||||
"WFWorkflowOutputContentItemClasses": [],
|
||||
"WFWorkflowNoInputBehavior": {
|
||||
"Name": "WFWorkflowNoInputBehaviorAskForInput",
|
||||
"Parameters": {
|
||||
"ItemClass": "WFStringContentItem",
|
||||
},
|
||||
},
|
||||
"WFWorkflowIcon": {
|
||||
"WFWorkflowIconGlyphNumber": 59723,
|
||||
"WFWorkflowIconStartColor": 4278222847,
|
||||
},
|
||||
"WFWorkflowActions": [
|
||||
{
|
||||
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": TEXT_UUID,
|
||||
"CustomOutputName": "快捷指令文本",
|
||||
"WFTextActionText": shortcut_input_text(),
|
||||
},
|
||||
},
|
||||
{
|
||||
"WFWorkflowActionIdentifier": "is.workflow.actions.openurl",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": OPEN_UUID,
|
||||
"WFURL": action_output_text("快捷指令文本", TEXT_UUID),
|
||||
"WFShowWhenRun": False,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
|
||||
print(f"wrote {UNSIGNED}")
|
||||
print(
|
||||
"sign with: shortcuts sign --mode anyone "
|
||||
f"--input {UNSIGNED} --output {SIGNED}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the companion Shortcut for the Save to Notes skill.
|
||||
|
||||
Writes an unsigned binary plist that `shortcuts sign` can notarize.
|
||||
|
||||
On iPhone, `com.apple.mobilenotes.SharingExtension` binds note content through
|
||||
`WFCreateNoteInput`. The similarly named `contents` App Intent parameter is
|
||||
ignored by this legacy action and leaves an unresolved Content placeholder.
|
||||
|
||||
Text (Shortcut Input)
|
||||
→ Split Text (`||OSG_NOTE||`)
|
||||
→ Get item 1 (title) and item 2 (body)
|
||||
→ Text (title + blank line + body)
|
||||
→ Create Note (WFCreateNoteInput = Text output, silent)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts"
|
||||
UNSIGNED = OUT_DIR / "OSGSaveToNotes.unsigned.shortcut"
|
||||
SIGNED = OUT_DIR / "OSGSaveToNotes.shortcut"
|
||||
|
||||
OBJECT_REPLACEMENT = "\ufffc"
|
||||
FIELD_SEPARATOR = "||OSG_NOTE||"
|
||||
|
||||
TEXT_UUID = "B2C3D4E0-1111-4E00-8A5A-FD7E1B0C396F"
|
||||
SPLIT_UUID = "B2C3D4E2-3333-4F11-9A6B-0E8F2C1D4A70"
|
||||
TITLE_UUID = "B2C3D4E3-4444-4014-B5E6-13B2A8C9D034"
|
||||
BODY_UUID = "B2C3D4E4-5555-4125-C6F7-24C3B9DAE145"
|
||||
NOTE_TEXT_UUID = "B2C3D4E5-6666-4236-D7A8-35D4CAEBF256"
|
||||
NOTE_UUID = "B2C3D4E1-2222-4F11-9A6B-0E8F2C1D4A70"
|
||||
|
||||
|
||||
def token_string(attachment_value: dict) -> dict:
|
||||
return {
|
||||
"Value": {
|
||||
"attachmentsByRange": {"{0, 1}": attachment_value},
|
||||
"string": OBJECT_REPLACEMENT,
|
||||
},
|
||||
"WFSerializationType": "WFTextTokenString",
|
||||
}
|
||||
|
||||
|
||||
def shortcut_input_text() -> dict:
|
||||
return token_string({"Type": "ExtensionInput"})
|
||||
|
||||
|
||||
def action_output_text(output_name: str, output_uuid: str) -> dict:
|
||||
return token_string(
|
||||
{
|
||||
"Aggrandizements": [],
|
||||
"OutputName": output_name,
|
||||
"OutputUUID": output_uuid,
|
||||
"Type": "ActionOutput",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def action_output_attachment(output_name: str, output_uuid: str) -> dict:
|
||||
return {
|
||||
"Value": {
|
||||
"Aggrandizements": [],
|
||||
"OutputName": output_name,
|
||||
"OutputUUID": output_uuid,
|
||||
"Type": "ActionOutput",
|
||||
},
|
||||
"WFSerializationType": "WFTextTokenAttachment",
|
||||
}
|
||||
|
||||
|
||||
def get_item(uuid: str, name: str, index: int) -> dict:
|
||||
return {
|
||||
"WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": uuid,
|
||||
"CustomOutputName": name,
|
||||
"WFInput": action_output_attachment("字段", SPLIT_UUID),
|
||||
"WFItemSpecifier": "Item At Index",
|
||||
"WFItemIndex": index,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def note_text() -> dict:
|
||||
return {
|
||||
"Value": {
|
||||
"attachmentsByRange": {
|
||||
"{0, 1}": {
|
||||
"Aggrandizements": [],
|
||||
"OutputName": "标题",
|
||||
"OutputUUID": TITLE_UUID,
|
||||
"Type": "ActionOutput",
|
||||
},
|
||||
"{3, 1}": {
|
||||
"Aggrandizements": [],
|
||||
"OutputName": "正文",
|
||||
"OutputUUID": BODY_UUID,
|
||||
"Type": "ActionOutput",
|
||||
},
|
||||
},
|
||||
"string": f"{OBJECT_REPLACEMENT}\n\n{OBJECT_REPLACEMENT}",
|
||||
},
|
||||
"WFSerializationType": "WFTextTokenString",
|
||||
}
|
||||
|
||||
|
||||
def create_note_action() -> dict:
|
||||
# The Create Note editor must show the `备忘录文本` magic-variable pill.
|
||||
# A generic `内容` placeholder means this key or value was not recognized.
|
||||
return {
|
||||
"WFWorkflowActionIdentifier": "com.apple.mobilenotes.SharingExtension",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": NOTE_UUID,
|
||||
"CustomOutputName": "备忘录",
|
||||
"IntentAppIdentifier": "com.apple.mobilenotes",
|
||||
"WFCreateNoteInput": action_output_text("备忘录文本", NOTE_TEXT_UUID),
|
||||
"ShowWhenRun": False,
|
||||
"WFShowWhenRun": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def workflow() -> dict:
|
||||
return {
|
||||
"WFWorkflowClientVersion": "3600.0.4",
|
||||
"WFWorkflowClientRelease": "26.0",
|
||||
"WFWorkflowMinimumClientVersion": 900,
|
||||
"WFWorkflowMinimumClientVersionString": "900",
|
||||
"WFWorkflowName": "OSGSaveToNotes",
|
||||
"WFWorkflowHasShortcutInputVariables": True,
|
||||
"WFWorkflowHasOutputFallback": False,
|
||||
"WFWorkflowImportQuestions": [],
|
||||
"WFWorkflowTypes": [],
|
||||
"WFWorkflowInputContentItemClasses": [
|
||||
"WFStringContentItem",
|
||||
"WFRichTextContentItem",
|
||||
],
|
||||
"WFWorkflowOutputContentItemClasses": [],
|
||||
"WFWorkflowNoInputBehavior": {
|
||||
"Name": "WFWorkflowNoInputBehaviorAskForInput",
|
||||
"Parameters": {
|
||||
"ItemClass": "WFStringContentItem",
|
||||
},
|
||||
},
|
||||
"WFWorkflowIcon": {
|
||||
"WFWorkflowIconGlyphNumber": 61440,
|
||||
"WFWorkflowIconStartColor": 2555137535,
|
||||
},
|
||||
"WFWorkflowActions": [
|
||||
{
|
||||
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": TEXT_UUID,
|
||||
"CustomOutputName": "快捷指令文本",
|
||||
"WFTextActionText": shortcut_input_text(),
|
||||
},
|
||||
},
|
||||
{
|
||||
"WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": SPLIT_UUID,
|
||||
"CustomOutputName": "字段",
|
||||
"text": action_output_text("快捷指令文本", TEXT_UUID),
|
||||
"WFTextSeparator": "Custom",
|
||||
"WFTextCustomSeparator": FIELD_SEPARATOR,
|
||||
},
|
||||
},
|
||||
get_item(TITLE_UUID, "标题", 1),
|
||||
get_item(BODY_UUID, "正文", 2),
|
||||
{
|
||||
"WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
|
||||
"WFWorkflowActionParameters": {
|
||||
"UUID": NOTE_TEXT_UUID,
|
||||
"CustomOutputName": "备忘录文本",
|
||||
"WFTextActionText": note_text(),
|
||||
},
|
||||
},
|
||||
create_note_action(),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def validate(data: dict) -> None:
|
||||
"""Reject bindings that make Create Note prompt for missing content."""
|
||||
actions = data["WFWorkflowActions"]
|
||||
create_note = actions[-1]
|
||||
parameters = create_note["WFWorkflowActionParameters"]
|
||||
note_input = parameters.get("WFCreateNoteInput")
|
||||
if create_note["WFWorkflowActionIdentifier"] != "com.apple.mobilenotes.SharingExtension":
|
||||
raise ValueError("last action must be Create Note")
|
||||
if "contents" in parameters or not note_input:
|
||||
raise ValueError("Create Note must use WFCreateNoteInput, not contents")
|
||||
if note_input.get("WFSerializationType") != "WFTextTokenString":
|
||||
raise ValueError("WFCreateNoteInput must be a Text magic-variable token")
|
||||
attachments = note_input["Value"]["attachmentsByRange"].values()
|
||||
if not any(item.get("OutputUUID") == NOTE_TEXT_UUID for item in attachments):
|
||||
raise ValueError("WFCreateNoteInput must reference the combined note text")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = workflow()
|
||||
validate(data)
|
||||
UNSIGNED.write_bytes(plistlib.dumps(data, fmt=plistlib.FMT_BINARY))
|
||||
print(f"wrote {UNSIGNED}")
|
||||
print(
|
||||
"sign with: shortcuts sign --mode anyone "
|
||||
f"--input {UNSIGNED} --output {SIGNED}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-2
@@ -49,7 +49,7 @@ settings:
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "1.7.5"
|
||||
CURRENT_PROJECT_VERSION: "69"
|
||||
CURRENT_PROJECT_VERSION: "71"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
@@ -96,7 +96,7 @@ targets:
|
||||
- path: OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
|
||||
type: file
|
||||
buildPhase: resources
|
||||
- path: OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut
|
||||
- path: OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut
|
||||
type: file
|
||||
buildPhase: resources
|
||||
entitlements:
|
||||
|
||||
Reference in New Issue
Block a user