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:
Rocky
2026-08-14 18:22:41 +08:00
parent 0a60006d2d
commit 320dc777c9
46 changed files with 1429 additions and 376 deletions
+1 -1
View File
@@ -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
View File
@@ -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 Notes 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 cards 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, Ive added it, and Reinstall are solid capsules — primary actions use the brand green (`#3AA05A`), secondary actions use an elevated fill. / **技能弹窗按钮**:添加、关闭、添加捷径、我已添加、重新安装改为实心胶囊;主操作为品牌绿(`#3AA05A`),次要为抬升底。
@@ -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(
/// 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)
Image(systemName: tab.dockSystemImage(selected: selection == tab))
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
} else {
MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize)
}
}
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 ~34×
/// 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
+2 -2
View File
@@ -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 {
+2
View File
@@ -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. Dont 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" = "添加后点「我已添加」,请勿改名。";
+23 -3
View File
@@ -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)
}
+46 -93
View File
@@ -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()
func handleMoved(id: ObjectIdentifier, at point: CGPoint) {
apply(tracker.moved(id: id, key: hit(at: point)))
}
if let key {
activate(key)
} else {
// Outside plane: clear highlight; Shift stays held until ended.
setHighlight(nil)
activeKeyID = nil
}
func handleEnded(id: ObjectIdentifier, at point: CGPoint) {
apply(tracker.ended(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 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)
private func apply(_ effects: TypingTouchEffects) {
if effects.stopDeleteRepeat {
stopDeleteRepeat()
}
for key in effects.commits {
parent.onCommit(key)
}
if let key = effects.playFeedback {
playFeedback(for: key)
switch key.behavior {
case .commitOnRelease:
break
case .deleteRepeat:
}
if effects.deleteFire {
parent.onDeleteFire()
}
if effects.startDeleteRepeat {
startDeleteRepeat()
case .shiftHold:
if !gestureOwnsShift {
gestureOwnsShift = true
}
if effects.beginShift {
parent.onShiftBegan()
}
if effects.endShift {
parent.onShiftEnded()
}
}
private func setHighlight(_ id: String?) {
parent.onHighlightChange(id)
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 }
}
}
+21 -33
View File
@@ -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")
}
+1 -1
View File
@@ -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()
+12
View File
@@ -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" = "Couldnt 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)
}
}
+4 -3
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -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
813
使 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 .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
}
}
+53 -25
View File
@@ -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"))
}
}
+150
View File
@@ -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
)
)!
}
}
+2 -1
View File
@@ -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": [],
+2 -1
View File
@@ -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": [],
-113
View File
@@ -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()
+217
View File
@@ -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
View File
@@ -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.xcconfiggitignored,不会被覆盖)
# 项目级签名 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: