diff --git a/.gitignore b/.gitignore
index 397ba4a..26f7e1b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,5 +87,6 @@ CHANGES.md
# Local scratch: DerivedData clones, stress logs, QA frames, AI notes, audits
.tmp/
+.ai-skills-layout-shots/
.workbuddy-ai/
reports/
diff --git a/AGENTS.md b/AGENTS.md
index b9db30d..599f6e9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 67)**. Releases use stable SemVer:
+The current source-of-truth version is **1.7.5 (build 71)**. Releases use stable SemVer:
| Field | File | Rule |
|-------|------|------|
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36062f7..2253082 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,31 @@ 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 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`),次要为抬升底。
- **AI idle hint keywords**: chips show the entity (from feed `metadata.title` / city / holiday name) instead of a long sentence; category prefixes like「全网热点:」are dropped, and LLM compression is only a last resort. / **AI 空闲建议关键词**:芯片展示实体(来自 feed 的 `metadata.title` / 城市 / 节日名)而不再是长句;去掉「全网热点:」一类前缀,LLM 压缩仅作兜底。
- **AI idle hint chrome**: each rotating suggestion sits in a Liquid Glass capsule with a category SF Symbol (calendar, weather, news, stocks, trending, search). / **AI 空闲建议样式**:轮播建议放入 Liquid Glass 胶囊,左侧为类型 SF Symbol(日历、天气、新闻、股票、热搜、搜索)。
- **Clipboard AI skills**: within 30 seconds of a copy, AI idle shows compact circular Reply / Summarize / Translate buttons; translate follows the keyboard language setting, or Chinese ↔ English when unset. The skill list is catalog-based so more actions can be added later. / **剪贴板 AI 技能**:复制后约 30 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。
@@ -16,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **AI idle capsule size**: rotating suggestion chips keep a 44 pt tap height with 16 pt side padding. / **AI 空闲胶囊尺寸**:轮播建议芯片保持 44 pt 点击高度,左右各 16 pt 内边距。
- **Undo / translation chrome**: voice mic-row undo and translation controls are 52 pt circular Liquid Glass buttons. / **撤销与翻译按钮**:语音麦克风行的撤销、翻译改为 52 pt 圆形 Liquid Glass 按钮。
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
-- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 风格 / 设置」文字。
+- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Skills / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 技能 / 风格 / 设置」文字。
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
### Fixed
diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist
index 1add840..78992f7 100644
--- a/OSGKeyboard/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -69,6 +69,11 @@
zalo
skype
zoomus
+ shortcuts
+ iosamap
+ amapuri
+ baidumap
+ maps
things
todoist
evernote
diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift
index 3021bfb..e9f8fb3 100644
--- a/OSGKeyboard/OSGKeyboardApp.swift
+++ b/OSGKeyboard/OSGKeyboardApp.swift
@@ -36,6 +36,8 @@ struct OSGKeyboardApp: App {
EditDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
AIKeyboardDemoView()
+ } else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
+ AIClipboardSkillLayoutDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
diff --git a/OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut b/OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut
new file mode 100644
index 0000000..c605178
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
new file mode 100644
index 0000000..1483444
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut
new file mode 100644
index 0000000..ceb1d7f
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
new file mode 100644
index 0000000..de38bd3
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut
new file mode 100644
index 0000000..084cc3c
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut
new file mode 100644
index 0000000..97ec20c
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut differ
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut
new file mode 100644
index 0000000..f25cddf
Binary files /dev/null and b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut differ
diff --git a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
new file mode 100644
index 0000000..ddbdea3
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
@@ -0,0 +1,41 @@
+// AIAgentShortcutInstaller.swift
+// OSGKeyboard · Main App
+//
+// Opening the HTTPS iCloud share page from the app is claimed as a
+// Universal Link and often lands on Gallery. `shortcuts://shortcuts/TOKEN`
+// opens the Add sheet in Shortcuts directly.
+
+import Foundation
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutInstaller {
+ @MainActor
+ static func openInstallPage(for skill: AIClipboardSkill) {
+ if let shareURL = skill.shortcutICloudURL,
+ let installURL = AIAgentShortcutRun.shortcutsInstallURL(from: shareURL) {
+ UIApplication.shared.open(installURL)
+ return
+ }
+ openBundledShortcut(for: skill)
+ }
+
+ @MainActor
+ private static func openBundledShortcut(for skill: AIClipboardSkill) {
+ guard let resource = skill.shortcutResourceName,
+ let bundled = Bundle.main.url(
+ forResource: resource,
+ withExtension: "shortcut"
+ ) else { return }
+ let fileName = skill.shortcutName ?? resource
+ let tmp = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(fileName).shortcut")
+ try? FileManager.default.removeItem(at: tmp)
+ do {
+ try FileManager.default.copyItem(at: bundled, to: tmp)
+ UIApplication.shared.open(tmp)
+ } catch {
+ UIApplication.shared.open(bundled)
+ }
+ }
+}
diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
new file mode 100644
index 0000000..260d93b
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
@@ -0,0 +1,99 @@
+// AIAgentShortcutRunner.swift
+// OSGKeyboard · Main App
+//
+// 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
+
+enum AIAgentShortcutRunner {
+ @MainActor
+ static func runPendingIfNeeded() {
+ 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) else {
+ AIAgentShortcutRun.trace(
+ "host.runPending skip unknownSkill=\(payload.skillID)"
+ )
+ return
+ }
+ if skill.id == AIClipboardSkillCatalog.navigateID {
+ openMap(for: payload)
+ return
+ }
+ guard let name = skill.shortcutName else {
+ AIAgentShortcutRun.trace(
+ "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)")
+ return
+ }
+ let textItem = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == "text" }?
+ .value
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts scheme=\(url.scheme ?? "") host=\(url.host ?? "") "
+ + "path=\(url.path) urlChars=\(url.absoluteString.count) "
+ + "textQueryChars=\(textItem?.count ?? -1) name=\(name)"
+ )
+ AIAgentShortcutRun.traceBody("host.textQuery", textItem ?? "")
+ UIApplication.shared.open(url) { success in
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts result success=\(success) "
+ + "(iOS accepted the URL; not proof Reminders were created)"
+ )
+ }
+ }
+
+ static func logShortcutCallback(_ url: URL) {
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let status = items.first { $0.name == "status" }?.value ?? "unknown"
+ let errorMessage = items.first { $0.name == "errorMessage" }?.value
+ let errorCode = items.first { $0.name == "errorCode" }?.value
+ AIAgentShortcutRun.trace(
+ "host.shortcutCallback status=\(status) errorCode=\(errorCode ?? "-") "
+ + "errorMessage=\(errorMessage ?? "-") "
+ + "(Shortcuts finished; does not prove reminder rows exist)"
+ )
+ }
+
+ /// 高德 → 百度 → 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)")
+ }
+ }
+
+ private static func shortcutsURL(name: String, text: String) -> URL? {
+ #if DEBUG
+ return AIAgentShortcutRun.shortcutsRunURL(
+ name: name,
+ text: text,
+ xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
+ xError: "osgkeyboard://skill/shortcut-result?status=error",
+ xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
+ )
+ #else
+ return AIAgentShortcutRun.shortcutsRunURL(name: name, text: text)
+ #endif
+ }
+}
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index 8a0eca9..349cafc 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -1358,13 +1358,15 @@ final class FlowSessionManager: ObservableObject {
let utteranceId = command.utteranceId
let commandSeq = command.commandSeq
let sessionId = command.sessionId
+ let thinkingEnabled = command.aiThinkingEnabled ?? true
Task { @MainActor [weak self] in
await self?.answerPrefilledAIQuestion(
question: question,
conversationID: conversationID,
sessionId: sessionId,
utteranceId: utteranceId,
- commandSeq: commandSeq
+ commandSeq: commandSeq,
+ thinkingEnabled: thinkingEnabled
)
}
}
@@ -1385,7 +1387,8 @@ final class FlowSessionManager: ObservableObject {
conversationID: UUID?,
sessionId: UUID,
utteranceId: UUID,
- commandSeq: Int64
+ commandSeq: Int64,
+ thinkingEnabled: Bool
) async {
// Hint-card / prefilled questions never go through `finalizeUtterance`,
// so this path must drop the processing gate itself. Leaving it set
@@ -1415,7 +1418,8 @@ final class FlowSessionManager: ObservableObject {
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
- conversations: aiConversations
+ conversations: aiConversations,
+ thinkingEnabled: thinkingEnabled
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
diff --git a/OSGKeyboard/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift
new file mode 100644
index 0000000..1ee1f59
--- /dev/null
+++ b/OSGKeyboard/Views/AIAgentSkillsView.swift
@@ -0,0 +1,9194 @@
+// AIAgentSkillsView.swift
+// OSGKeyboard · Main App
+//
+// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
+// the keyboard after a copy; long-press drag rearranges that row live,
+// like Home Screen icons. Export skills confirm a companion Shortcut
+// before they can occupy a slot. Users can add custom export skills.
+
+import SwiftUI
+import UIKit
+import OSGKeyboardShared
+
+struct AIAgentSkillsView: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject private var config = ProviderConfig.shared
+ @ObservedObject private var store = AIAgentSkillLayoutStore.shared
+
+ @State private var viewingSkill: AIClipboardSkill?
+ @State private var editingDraft: SkillEditorDraft?
+ @State private var showFullAlert = false
+ @State private var showClipboardSettings = false
+ /// True while a skill card is lifted; locks the page scroll like SpringBoard.
+ @State private var isReordering = false
+
+ private let columns = [
+ GridItem(.flexible(), spacing: Spacing.sm),
+ GridItem(.flexible(), spacing: Spacing.sm),
+ ]
+
+ private var skillCardShape: RoundedRectangle {
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ CardPageContent(spacing: Spacing.xl) {
+ if !config.clipboardHistoryEnabled {
+ clipboardHistoryBanner
+ }
+ enabledSection
+ if !store.availableSkills.isEmpty {
+ availableSection
+ }
+ }
+ .tabBarScrollBottomPadding()
+ }
+ .scrollDisabled(isReordering)
+ .background(palette.background)
+ .navigationTitle("skills.title")
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ editingDraft = .blank()
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel(Text("skills.add"))
+ }
+ }
+ .navigationDestination(isPresented: $showClipboardSettings) {
+ ClipboardSettingsView(config: config)
+ }
+ }
+ .sheet(item: $viewingSkill) { skill in
+ SkillDetailSheet(
+ store: store,
+ skill: skill,
+ onDismiss: { viewingSkill = nil },
+ onAddOrWarn: addOrWarn,
+ onConfirmInstall: confirmShortcutInstall
+ )
+ }
+ .sheet(item: $editingDraft) { draft in
+ SkillEditorSheet(
+ draft: draft,
+ onSave: { try saveDraft($0) },
+ onDelete: { id in
+ store.deleteUserSkill(id: id)
+ editingDraft = nil
+ }
+ )
+ }
+ .alert(
+ AppL10n.string("skills.full.title", language: config.uiLanguage),
+ isPresented: $showFullAlert
+ ) {
+ Button("common.done") { showFullAlert = false }
+ } message: {
+ Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
+ }
+ .onAppear { store.reload() }
+ }
+
+ private var clipboardHistoryBanner: some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ Label {
+ Text("skills.clipboard.guide.title")
+ .font(TypeStyle.bodyEmph)
+ .foregroundStyle(palette.textPrimary)
+ } icon: {
+ Image(systemName: "clipboard")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.warning)
+ }
+ Text("skills.clipboard.guide.body")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button {
+ showClipboardSettings = true
+ } label: {
+ guideRow(
+ titleKey: "skills.clipboard.guide.openAppSettings",
+ systemImage: "slider.horizontal.3",
+ trailing: "chevron.right"
+ )
+ }
+ .buttonStyle(.plain)
+
+ Divider().background(palette.divider)
+
+ Button {
+ AppPermissions.openSystemSettings()
+ } label: {
+ guideRow(
+ titleKey: "skills.clipboard.guide.openSystemSettings",
+ systemImage: "gearshape",
+ trailing: "arrow.up.right"
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(Spacing.md)
+ .background(palette.surface, in: skillCardShape)
+ .overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
+ }
+
+ private func guideRow(
+ titleKey: LocalizedStringKey,
+ systemImage: String,
+ trailing: String
+ ) -> some View {
+ HStack(spacing: Spacing.sm) {
+ Image(systemName: systemImage)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .frame(width: 22)
+ Text(titleKey)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ Spacer(minLength: 0)
+ Image(systemName: trailing)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(palette.textTertiary)
+ }
+ .padding(.vertical, Spacing.xs)
+ .contentShape(Rectangle())
+ }
+
+ private var enabledSection: some View {
+ CardSection(
+ title: AppL10n.format(
+ "skills.enabled.section",
+ language: config.uiLanguage,
+ store.enabledSkills.count,
+ AIAgentSkillLayout.maximumEnabled
+ )
+ ) {
+ if store.enabledSkills.isEmpty {
+ Text("skills.enabled.empty")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ EnabledSkillsReorderGrid(
+ skills: store.enabledSkills,
+ isReordering: $isReordering,
+ onTap: handleCardTap,
+ onEdit: handleEdit,
+ onMoveToIndex: { id, index in
+ store.moveEnabled(id: id, toIndex: index)
+ },
+ card: { skill in
+ skillCardVisual(skill, isEnabled: true)
+ }
+ )
+ }
+ }
+ }
+
+ private var availableSection: some View {
+ CardSection("skills.available.section") {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.availableSkills) { skill in
+ skillCardInteractive(skill, isEnabled: false)
+ }
+ }
+ }
+ }
+
+ private func skillCardInteractive(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
+ ZStack(alignment: .topTrailing) {
+ Button {
+ handleCardTap(skill)
+ } label: {
+ skillCardVisual(skill, isEnabled: isEnabled)
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ handleEdit(skill)
+ } label: {
+ Color.clear
+ .frame(
+ width: CatalogCardChrome.editHitSize,
+ height: CatalogCardChrome.editHitSize
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(Text("skills.edit"))
+ }
+ .contextMenu {
+ if skill.isUserCreated {
+ Button("common.delete", role: .destructive) {
+ store.deleteUserSkill(id: skill.id)
+ }
+ }
+ }
+ }
+
+ private func skillCardVisual(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
+ ZStack(alignment: .topTrailing) {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ Text(skillTitle(skill))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ .lineLimit(1)
+ .padding(.trailing, 32)
+ Text(skillDescription(skill))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
+ .padding(Spacing.md)
+
+ CatalogCardChrome.editIcon(palette: palette)
+ .padding(Spacing.sm)
+ .allowsHitTesting(false)
+
+ if isEnabled {
+ CatalogCardChrome.checkIcon(palette: palette)
+ }
+ }
+ .background(
+ isEnabled ? palette.accentMuted : palette.surface,
+ in: skillCardShape
+ )
+ .overlay(skillCardShape.stroke(
+ isEnabled ? palette.accent : palette.divider,
+ lineWidth: isEnabled ? 1.5 : 0.5
+ ))
+ .clipShape(skillCardShape)
+ .contentShape(skillCardShape)
+ .containerShape(skillCardShape)
+ }
+
+ private func skillTitle(_ skill: AIClipboardSkill) -> String {
+ if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ return name
+ }
+ return AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)
+ }
+
+ private func skillDescription(_ skill: AIClipboardSkill) -> String {
+ if skill.isUserCreated {
+ let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !summary.isEmpty { return summary }
+ return AppL10n.string("skills.custom.description", language: config.uiLanguage)
+ }
+ return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
+ }
+
+ private func handleCardTap(_ skill: AIClipboardSkill) {
+ if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
+ viewingSkill = skill
+ return
+ }
+ if store.layout.isEnabled(skill.id) {
+ store.disable(skill.id)
+ } else {
+ addOrWarn(skill)
+ }
+ }
+
+ private func handleEdit(_ skill: AIClipboardSkill) {
+ if skill.isUserCreated, let user = store.userSkill(id: skill.id) {
+ editingDraft = .from(user)
+ } else {
+ viewingSkill = skill
+ }
+ }
+
+ private func addOrWarn(_ skill: AIClipboardSkill) {
+ switch store.enable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+
+ private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
+ switch store.confirmShortcutAndEnable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+
+ private func saveDraft(_ draft: SkillEditorDraft) throws {
+ guard let url = AIShortcutShareLink.parse(draft.shortcutLink) else {
+ throw AIUserSkillValidationError.invalidShortcutLink
+ }
+ let skill = AIUserSkill(
+ id: draft.id,
+ name: draft.name,
+ summary: draft.summary,
+ systemImage: draft.systemImage,
+ prompt: draft.prompt,
+ shortcutICloudURL: url,
+ shortcutName: draft.shortcutName,
+ thinkingEnabled: draft.thinkingEnabled
+ )
+ try store.saveUserSkill(skill)
+ editingDraft = nil
+ }
+}
+
+/// Home-screen style reorder: long-press lifts the card, other cells slide
+/// into the gap as the finger crosses midpoints, drop settles in place.
+private struct EnabledSkillsReorderGrid: View {
+ let skills: [AIClipboardSkill]
+ @Binding var isReordering: Bool
+ let onTap: (AIClipboardSkill) -> Void
+ let onEdit: (AIClipboardSkill) -> Void
+ let onMoveToIndex: (String, Int) -> Void
+ let card: (AIClipboardSkill) -> Card
+
+ @State private var draggingID: String?
+ @State private var dragTranslation: CGSize = .zero
+ @State private var originFrame: CGRect = .zero
+ @State private var cellFrames: [String: CGRect] = [:]
+ @State private var ignoreTap = false
+
+ private static var spaceName: String { "enabledSkillsGrid" }
+ private let columns = [
+ GridItem(.flexible(), spacing: Spacing.sm),
+ GridItem(.flexible(), spacing: Spacing.sm),
+ ]
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(skills) { skill in
+ gridCell(skill)
+ }
+ }
+ .animation(
+ .interactiveSpring(response: 0.28, dampingFraction: 0.86),
+ value: skills.map(\.id)
+ )
+
+ if let draggingID, let skill = skills.first(where: { $0.id == draggingID }) {
+ card(skill)
+ .frame(width: originFrame.width, height: originFrame.height)
+ .scaleEffect(1.07)
+ .shadow(color: .black.opacity(0.28), radius: 16, y: 10)
+ .offset(
+ x: originFrame.minX + dragTranslation.width,
+ y: originFrame.minY + dragTranslation.height
+ )
+ .allowsHitTesting(false)
+ }
+ }
+ .coordinateSpace(.named(Self.spaceName))
+ .onPreferenceChange(SkillCellFrameKey.self) { cellFrames = $0 }
+ }
+
+ private func gridCell(_ skill: AIClipboardSkill) -> some View {
+ ZStack(alignment: .topTrailing) {
+ Button {
+ guard draggingID == nil, !ignoreTap else { return }
+ onTap(skill)
+ } label: {
+ card(skill)
+ .opacity(draggingID == skill.id ? 0 : 1)
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ guard draggingID == nil, !ignoreTap else { return }
+ onEdit(skill)
+ } label: {
+ Color.clear
+ .frame(
+ width: CatalogCardChrome.editHitSize,
+ height: CatalogCardChrome.editHitSize
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(Text("skills.edit"))
+ .opacity(draggingID == skill.id ? 0 : 1)
+ }
+ .background {
+ GeometryReader { proxy in
+ Color.clear.preference(
+ key: SkillCellFrameKey.self,
+ value: [skill.id: proxy.frame(in: .named(Self.spaceName))]
+ )
+ }
+ }
+ .simultaneousGesture(reorderGesture(for: skill))
+ }
+
+ private func reorderGesture(for skill: AIClipboardSkill) -> some Gesture {
+ LongPressGesture(minimumDuration: 0.22)
+ .sequenced(
+ before: DragGesture(
+ minimumDistance: 0,
+ coordinateSpace: .named(Self.spaceName)
+ )
+ )
+ .onChanged { value in
+ switch value {
+ case .second(_, let drag):
+ liftIfNeeded(skill)
+ guard let drag else { return }
+ dragTranslation = drag.translation
+ moveSlotIfNeeded(at: drag.location)
+ default:
+ break
+ }
+ }
+ .onEnded { _ in
+ endDrag()
+ }
+ }
+
+ private func liftIfNeeded(_ skill: AIClipboardSkill) {
+ guard draggingID == nil else { return }
+ originFrame = cellFrames[skill.id] ?? .zero
+ draggingID = skill.id
+ isReordering = true
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred(intensity: 0.9)
+ }
+
+ private func moveSlotIfNeeded(at point: CGPoint) {
+ guard let draggingID else { return }
+ guard let target = nearestIndex(to: point) else { return }
+ guard skills[target].id != draggingID else { return }
+ UISelectionFeedbackGenerator().selectionChanged()
+ onMoveToIndex(draggingID, target)
+ }
+
+ private func nearestIndex(to point: CGPoint) -> Int? {
+ guard !skills.isEmpty else { return nil }
+ var bestIndex: Int?
+ var bestDistance = CGFloat.greatestFiniteMagnitude
+ for (index, skill) in skills.enumerated() {
+ guard let frame = cellFrames[skill.id] else { continue }
+ let dx = point.x - frame.midX
+ let dy = point.y - frame.midY
+ let distance = dx * dx + dy * dy
+ if distance < bestDistance {
+ bestDistance = distance
+ bestIndex = index
+ }
+ }
+ return bestIndex
+ }
+
+ private func endDrag() {
+ let wasDragging = draggingID != nil
+ if let draggingID, let slot = cellFrames[draggingID], originFrame.width > 0 {
+ withAnimation(.snappy(duration: 0.2)) {
+ dragTranslation = CGSize(
+ width: slot.minX - originFrame.minX,
+ height: slot.minY - originFrame.minY
+ )
+ } completion: {
+ clearDragState()
+ }
+ } else {
+ clearDragState()
+ }
+ guard wasDragging else { return }
+ ignoreTap = true
+ DispatchQueue.main.async {
+ ignoreTap = false
+ }
+ }
+
+ private func clearDragState() {
+ draggingID = nil
+ dragTranslation = .zero
+ isReordering = false
+ }
+}
+
+private struct SkillCellFrameKey: PreferenceKey {
+ static let defaultValue: [String: CGRect] = [:]
+
+ static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
+ }
+}
+
+private struct SkillEditorDraft: Identifiable, Equatable {
+ let id: String
+ let isNew: Bool
+ var name: String
+ var summary: String
+ var systemImage: String
+ var prompt: String
+ var shortcutLink: String
+ var shortcutName: String
+ var thinkingEnabled: Bool
+
+ static func blank() -> SkillEditorDraft {
+ SkillEditorDraft(
+ id: "user.\(UUID().uuidString.lowercased())",
+ isNew: true,
+ name: "",
+ summary: "",
+ systemImage: AIUserSkillLimits.defaultSystemImage,
+ prompt: AIUserSkillLimits.newPromptTemplate,
+ shortcutLink: "",
+ shortcutName: "",
+ thinkingEnabled: false
+ )
+ }
+
+ static func from(_ skill: AIUserSkill) -> SkillEditorDraft {
+ SkillEditorDraft(
+ id: skill.id,
+ isNew: false,
+ name: skill.name,
+ summary: skill.summary,
+ systemImage: skill.systemImage,
+ prompt: skill.prompt,
+ shortcutLink: skill.shortcutICloudURL.absoluteString,
+ shortcutName: skill.shortcutName,
+ thinkingEnabled: skill.thinkingEnabled
+ )
+ }
+}
+
+private struct SkillDetailSheet: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject var store: AIAgentSkillLayoutStore
+ @ObservedObject private var config = ProviderConfig.shared
+
+ let skill: AIClipboardSkill
+ let onDismiss: () -> Void
+ let onAddOrWarn: (AIClipboardSkill) -> Void
+ let onConfirmInstall: (AIClipboardSkill) -> Void
+
+ /// Inner stack height; nav chrome + home indicator are added for the detent.
+ @State private var contentHeight: CGFloat = 280
+ private let navigationChrome: CGFloat = 72
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ CardPageContent(
+ spacing: Spacing.md,
+ topPadding: Spacing.md,
+ bottomPadding: Spacing.xl
+ ) {
+ header
+ Text(skillDescription)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ promptBlock
+ thinkingRow
+ skillActions
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { newHeight in
+ let next = newHeight + navigationChrome
+ if abs(contentHeight - next) > 1 {
+ contentHeight = next
+ }
+ }
+ }
+ .scrollBounceBehavior(.basedOnSize)
+ .background(palette.background)
+ .navigationTitle("skills.detail.title")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("common.done", action: onDismiss)
+ }
+ }
+ }
+ .presentationDetents([.height(clampedSheetHeight)])
+ .presentationDragIndicator(.visible)
+ .presentationContentInteraction(.resizes)
+ .animation(.easeInOut(duration: 0.2), value: clampedSheetHeight)
+ }
+
+ private var clampedSheetHeight: CGFloat {
+ let screen = UIScreen.main.bounds.height
+ let maximum = screen * 0.92
+ return min(max(contentHeight, 280), maximum)
+ }
+
+ private var skillDescription: String {
+ if skill.isUserCreated {
+ let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !summary.isEmpty { return summary }
+ return AppL10n.string("skills.custom.description", language: config.uiLanguage)
+ }
+ if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
+ return AppL10n.string("skills.install.lead", language: config.uiLanguage)
+ }
+ if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
+ return AppL10n.string("skills.action.turnOffHint", language: config.uiLanguage)
+ }
+ return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
+ }
+
+ private var promptText: String {
+ AIClipboardSkillCatalog.instruction(
+ for: skill,
+ locale: config.uiLanguage.resolvedLanguageCode() == "zh-Hans" ? "zh" : "en",
+ translationTargetLocaleId: config.translationTargetLocaleId
+ )
+ }
+
+ private var header: some View {
+ HStack(alignment: .center, spacing: Spacing.md) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .frame(width: 36, height: 36)
+ .background(palette.accentMuted, in: Circle())
+ VStack(alignment: .leading, spacing: 2) {
+ Text(headerTitle)
+ .font(TypeStyle.title3)
+ .foregroundStyle(palette.textPrimary)
+ if skill.isDefault {
+ Text("skills.badge.default")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private var headerTitle: String {
+ if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ return name
+ }
+ return AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)
+ }
+
+ private var promptBlock: some View {
+ VStack(alignment: .leading, spacing: Spacing.xs) {
+ Text("skills.editor.prompt")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ Text(promptText)
+ .font(.body.monospaced())
+ .foregroundStyle(palette.textPrimary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(Spacing.md)
+ .surfaceCard()
+ }
+ }
+
+ private var thinkingRow: some View {
+ Toggle("skills.editor.thinking", isOn: .constant(skill.thinkingEnabled))
+ .disabled(true)
+ .font(TypeStyle.body)
+ }
+
+ @ViewBuilder
+ private var skillActions: some View {
+ let enabled = store.layout.isEnabled(skill.id)
+ if skill.requiresShortcut {
+ if !store.layout.hasConfirmedShortcut(skill.id) {
+ shortcutInstallBlock
+ } else {
+ if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
+ openShortcutInstall()
+ }
+ }
+ } else if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ }
+
+ private var shortcutInstallBlock: some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ fullWidthButton("skills.install.openShortcuts", prominent: true) {
+ openShortcutInstall()
+ }
+ fullWidthButton("skills.install.confirmAdded", prominent: false) {
+ onConfirmInstall(skill)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fullWidthButton(
+ _ titleKey: LocalizedStringKey,
+ prominent: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button(action: action) {
+ Text(titleKey)
+ .font(TypeStyle.bodyEmph)
+ .frame(maxWidth: .infinity, minHeight: 44)
+ .foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary)
+ .background(
+ prominent ? palette.accent : palette.surfaceElevated,
+ in: Capsule()
+ )
+ .overlay(
+ Capsule()
+ .stroke(prominent ? Color.clear : palette.dividerStrong, lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func openShortcutInstall() {
+ AIAgentShortcutInstaller.openInstallPage(for: skill)
+ }
+}
+
+private struct SkillEditorSheet: View {
+ let draft: SkillEditorDraft
+ let onSave: (SkillEditorDraft) throws -> Void
+ let onDelete: (String) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.themePalette) private var palette
+ @State private var name: String
+ @State private var summary: String
+ @State private var systemImage: String
+ @State private var prompt: String
+ @State private var shortcutLink: String
+ @State private var shortcutName: String
+ @State private var thinkingEnabled: Bool
+ @State private var showSymbolPicker = false
+ @State private var isLookingUp = false
+ @State private var lookupMessage: String?
+ @State private var lookupTask: Task?
+ @State private var saveError: String?
+ @State private var confirmDelete = false
+
+ init(
+ draft: SkillEditorDraft,
+ onSave: @escaping (SkillEditorDraft) throws -> Void,
+ onDelete: @escaping (String) -> Void
+ ) {
+ self.draft = draft
+ self.onSave = onSave
+ self.onDelete = onDelete
+ _name = State(initialValue: draft.name)
+ _summary = State(initialValue: draft.summary)
+ _systemImage = State(initialValue: draft.systemImage)
+ _prompt = State(initialValue: draft.prompt)
+ _shortcutLink = State(initialValue: draft.shortcutLink)
+ _shortcutName = State(initialValue: draft.shortcutName)
+ _thinkingEnabled = State(initialValue: draft.thinkingEnabled)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("skills.editor.name") {
+ TextField("skills.editor.namePlaceholder", text: $name)
+ }
+ Section("skills.editor.summary") {
+ TextField("skills.editor.summaryPlaceholder", text: $summary, axis: .vertical)
+ .lineLimit(6...12)
+ .frame(minHeight: 108, alignment: .top)
+ }
+ Section("skills.editor.icon") {
+ Button {
+ showSymbolPicker = true
+ } label: {
+ HStack(spacing: Spacing.md) {
+ Image(systemName: systemImage)
+ .font(.system(size: 22, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .frame(width: 44, height: 44)
+ .background(
+ palette.accentMuted,
+ in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
+ )
+ VStack(alignment: .leading, spacing: 2) {
+ Text("skills.editor.iconChoose")
+ .foregroundStyle(palette.textPrimary)
+ Text(systemImage)
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(palette.textTertiary)
+ }
+ }
+ }
+ Section {
+ TextEditor(text: $prompt)
+ .font(.body.monospaced())
+ .frame(minHeight: 180)
+ } header: {
+ HStack {
+ Text("skills.editor.prompt")
+ Spacer()
+ Text("\(prompt.count)/\(AIUserSkillLimits.maximumPromptCharacters)")
+ .foregroundStyle(
+ prompt.count > AIUserSkillLimits.maximumPromptCharacters
+ ? palette.danger
+ : palette.textTertiary
+ )
+ }
+ }
+ Section {
+ TextField("skills.editor.linkPlaceholder", text: $shortcutLink)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.URL)
+ .autocorrectionDisabled()
+ TextField("skills.editor.shortcutNamePlaceholder", text: $shortcutName)
+ if isLookingUp {
+ Text("skills.editor.lookingUp")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ } else if let lookupMessage {
+ Text(lookupMessage)
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ }
+ } header: {
+ Text("skills.editor.shortcut")
+ } footer: {
+ Text("skills.editor.shortcutHint")
+ }
+ Section {
+ Toggle("skills.editor.thinking", isOn: $thinkingEnabled)
+ } footer: {
+ Text("skills.editor.thinkingHint")
+ }
+ if !draft.isNew {
+ Section {
+ Button("common.delete", role: .destructive) {
+ confirmDelete = true
+ }
+ }
+ }
+ }
+ .navigationTitle(draft.isNew ? "skills.add" : "skills.edit")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("common.cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("common.save") {
+ do {
+ try save()
+ dismiss()
+ } catch {
+ saveError = localizedSaveError(error)
+ }
+ }
+ .disabled(!canSave)
+ }
+ }
+ .sheet(isPresented: $showSymbolPicker) {
+ SkillSymbolPicker(selection: $systemImage)
+ }
+ .alert("skills.delete.title", isPresented: $confirmDelete) {
+ Button("common.cancel", role: .cancel) {}
+ Button("common.delete", role: .destructive) {
+ onDelete(draft.id)
+ dismiss()
+ }
+ } message: {
+ Text("skills.delete.message")
+ }
+ .alert(
+ Text("skills.error.title"),
+ isPresented: Binding(
+ get: { saveError != nil },
+ set: { if !$0 { saveError = nil } }
+ )
+ ) {
+ Button("common.done") { saveError = nil }
+ } message: {
+ Text(saveError ?? "")
+ }
+ .onChange(of: shortcutLink) { _, newValue in
+ lookupShortcutNameIfNeeded(from: newValue)
+ }
+ .onDisappear {
+ lookupTask?.cancel()
+ }
+ }
+ }
+
+ private var canSave: Bool {
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && prompt.count <= AIUserSkillLimits.maximumPromptCharacters
+ && !shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && AIShortcutShareLink.parse(shortcutLink) != nil
+ }
+
+ private var currentDraft: SkillEditorDraft {
+ var next = draft
+ next.name = name
+ next.summary = summary
+ next.systemImage = systemImage
+ next.prompt = prompt
+ next.shortcutLink = shortcutLink
+ next.shortcutName = shortcutName
+ next.thinkingEnabled = thinkingEnabled
+ return next
+ }
+
+ private func save() throws {
+ try onSave(currentDraft)
+ }
+
+ private func localizedSaveError(_ error: Error) -> String {
+ switch error as? AIUserSkillValidationError {
+ case .emptyName: return AppL10n.string("skills.error.emptyName")
+ case .emptyPrompt: return AppL10n.string("skills.error.emptyPrompt")
+ case .emptyShortcutName: return AppL10n.string("skills.error.emptyShortcutName")
+ case .invalidShortcutLink: return AppL10n.string("skills.error.invalidLink")
+ case .emptyIcon: return AppL10n.string("skills.error.emptyIcon")
+ case .promptTooLong: return AppL10n.string("skills.error.promptTooLong")
+ case nil: return AppL10n.string("skills.error.generic")
+ }
+ }
+
+ private func lookupShortcutNameIfNeeded(from raw: String) {
+ lookupTask?.cancel()
+ guard let url = AIShortcutShareLink.parse(raw) else {
+ lookupMessage = nil
+ isLookingUp = false
+ return
+ }
+ isLookingUp = true
+ lookupMessage = nil
+ lookupTask = Task {
+ do {
+ let fetched = try await AIShortcutShareMetadata.fetchName(from: url)
+ try Task.checkCancellation()
+ await MainActor.run {
+ isLookingUp = false
+ if shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ shortcutName = fetched
+ }
+ lookupMessage = AppL10n.format(
+ "skills.editor.resolvedName",
+ language: nil,
+ fetched
+ )
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ await MainActor.run {
+ isLookingUp = false
+ lookupMessage = AppL10n.string("skills.editor.lookupFailed")
+ }
+ }
+ }
+ }
+}
+
+private enum SFSymbolCatalog {
+ /// Full SF Symbol names from CoreGlyphs `name_availability.plist`.
+ /// Kept in source (not a bundle resource) so the existing Xcode project
+ /// does not need `xcodegen generate` to pick up a new file.
+ static let names: [String] = catalogText.split(whereSeparator: \.isNewline).map(String.init)
+
+ private static let catalogText = """
+0.circle
+0.circle.fill
+0.square
+0.square.fill
+00.circle
+00.circle.fill
+00.square
+00.square.fill
+01.circle
+01.circle.fill
+01.square
+01.square.fill
+02.circle
+02.circle.fill
+02.square
+02.square.fill
+03.circle
+03.circle.fill
+03.square
+03.square.fill
+04.circle
+04.circle.fill
+04.square
+04.square.fill
+05.circle
+05.circle.fill
+05.square
+05.square.fill
+06.circle
+06.circle.fill
+06.square
+06.square.fill
+07.circle
+07.circle.fill
+07.square
+07.square.fill
+08.circle
+08.circle.fill
+08.square
+08.square.fill
+09.circle
+09.circle.fill
+09.square
+09.square.fill
+1.brakesignal
+1.calendar
+1.circle
+1.circle.fill
+1.lane
+1.magnifyingglass
+1.square
+1.square.fill
+10.arrow.trianglehead.clockwise
+10.arrow.trianglehead.counterclockwise
+10.calendar
+10.circle
+10.circle.fill
+10.lane
+10.square
+10.square.fill
+11.calendar
+11.circle
+11.circle.fill
+11.lane
+11.square
+11.square.fill
+12.calendar
+12.circle
+12.circle.fill
+12.lane
+12.square
+12.square.fill
+123.rectangle
+123.rectangle.fill
+13.calendar
+13.circle
+13.circle.fill
+13.square
+13.square.fill
+14.calendar
+14.circle
+14.circle.fill
+14.square
+14.square.fill
+15.arrow.trianglehead.clockwise
+15.arrow.trianglehead.counterclockwise
+15.calendar
+15.circle
+15.circle.fill
+15.square
+15.square.fill
+16.calendar
+16.circle
+16.circle.fill
+16.square
+16.square.fill
+17.calendar
+17.circle
+17.circle.fill
+17.square
+17.square.fill
+18.calendar
+18.circle
+18.circle.fill
+18.square
+18.square.fill
+19.calendar
+19.circle
+19.circle.fill
+19.square
+19.square.fill
+2.brakesignal
+2.calendar
+2.circle
+2.circle.fill
+2.lane
+2.square
+2.square.fill
+20.calendar
+20.circle
+20.circle.fill
+20.square
+20.square.fill
+21.calendar
+21.circle
+21.circle.fill
+21.square
+21.square.fill
+22.calendar
+22.circle
+22.circle.fill
+22.square
+22.square.fill
+23.calendar
+23.circle
+23.circle.fill
+23.square
+23.square.fill
+24.calendar
+24.circle
+24.circle.fill
+24.square
+24.square.fill
+25.calendar
+25.circle
+25.circle.fill
+25.square
+25.square.fill
+26.calendar
+26.circle
+26.circle.fill
+26.square
+26.square.fill
+27.calendar
+27.circle
+27.circle.fill
+27.square
+27.square.fill
+28.calendar
+28.circle
+28.circle.fill
+28.square
+28.square.fill
+29.calendar
+29.circle
+29.circle.fill
+29.square
+29.square.fill
+2h
+2h.circle
+2h.circle.fill
+3.calendar
+3.circle
+3.circle.fill
+3.lane
+3.square
+3.square.fill
+30.arrow.trianglehead.clockwise
+30.arrow.trianglehead.counterclockwise
+30.calendar
+30.circle
+30.circle.fill
+30.square
+30.square.fill
+31.calendar
+31.circle
+31.circle.fill
+31.square
+31.square.fill
+32.circle
+32.circle.fill
+32.square
+32.square.fill
+33.circle
+33.circle.fill
+33.square
+33.square.fill
+34.circle
+34.circle.fill
+34.square
+34.square.fill
+35.circle
+35.circle.fill
+35.square
+35.square.fill
+36.circle
+36.circle.fill
+36.square
+36.square.fill
+37.circle
+37.circle.fill
+37.square
+37.square.fill
+38.circle
+38.circle.fill
+38.square
+38.square.fill
+39.circle
+39.circle.fill
+39.square
+39.square.fill
+4.alt.circle
+4.alt.circle.fill
+4.alt.square
+4.alt.square.fill
+4.calendar
+4.circle
+4.circle.fill
+4.lane
+4.square
+4.square.fill
+40.circle
+40.circle.fill
+40.square
+40.square.fill
+41.circle
+41.circle.fill
+41.square
+41.square.fill
+42.circle
+42.circle.fill
+42.square
+42.square.fill
+43.circle
+43.circle.fill
+43.square
+43.square.fill
+44.circle
+44.circle.fill
+44.square
+44.square.fill
+45.arrow.trianglehead.clockwise
+45.arrow.trianglehead.counterclockwise
+45.circle
+45.circle.fill
+45.square
+45.square.fill
+46.circle
+46.circle.fill
+46.square
+46.square.fill
+47.circle
+47.circle.fill
+47.square
+47.square.fill
+48.circle
+48.circle.fill
+48.square
+48.square.fill
+49.circle
+49.circle.fill
+49.square
+49.square.fill
+4a
+4a.circle
+4a.circle.fill
+4h
+4h.circle
+4h.circle.fill
+4k.tv
+4k.tv.fill
+4l
+4l.circle
+4l.circle.fill
+5.arrow.trianglehead.clockwise
+5.arrow.trianglehead.counterclockwise
+5.calendar
+5.circle
+5.circle.fill
+5.lane
+5.square
+5.square.fill
+50.circle
+50.circle.fill
+50.square
+50.square.fill
+6.alt.circle
+6.alt.circle.fill
+6.alt.square
+6.alt.square.fill
+6.calendar
+6.circle
+6.circle.fill
+6.lane
+6.square
+6.square.fill
+60.arrow.trianglehead.clockwise
+60.arrow.trianglehead.counterclockwise
+7.calendar
+7.circle
+7.circle.fill
+7.lane
+7.square
+7.square.fill
+75.arrow.trianglehead.clockwise
+75.arrow.trianglehead.counterclockwise
+8.calendar
+8.circle
+8.circle.fill
+8.lane
+8.square
+8.square.fill
+9.alt.circle
+9.alt.circle.fill
+9.alt.square
+9.alt.square.fill
+9.calendar
+9.circle
+9.circle.fill
+9.lane
+9.square
+9.square.fill
+90.arrow.trianglehead.clockwise
+90.arrow.trianglehead.counterclockwise
+a
+a.book.closed
+a.book.closed.fill
+a.circle
+a.circle.fill
+a.magnify
+a.square
+a.square.fill
+abc
+abs
+abs.brakesignal
+abs.brakesignal.slash
+abs.circle
+abs.circle.fill
+ac
+ac.slash
+accessibility
+accessibility.badge.arrow.up.right
+accessibility.fill
+air.car.side
+air.car.side.fill
+air.conditioner
+air.conditioner.horizontal
+air.conditioner.horizontal.fill
+air.conditioner.slash
+air.conditioner.vertical
+air.conditioner.vertical.fill
+air.convertible.side
+air.convertible.side.fill
+air.pickup.side
+air.pickup.side.fill
+air.purifier
+air.purifier.fill
+air.suv.side
+air.suv.side.fill
+airplane
+airplane.arrival
+airplane.circle
+airplane.circle.fill
+airplane.cloud
+airplane.departure
+airplane.landed
+airplane.path.dotted
+airplane.ticket
+airplane.ticket.fill
+airplane.up.forward
+airplane.up.forward.app
+airplane.up.forward.app.fill
+airplane.up.right
+airplane.up.right.app
+airplane.up.right.app.fill
+airplaneseat
+airplay.audio
+airplay.audio.badge.exclamationmark
+airplay.audio.circle
+airplay.audio.circle.fill
+airplay.video
+airplay.video.badge.exclamationmark
+airplay.video.circle
+airplay.video.circle.fill
+airplayaudio
+airplayaudio.badge.exclamationmark
+airplayaudio.circle
+airplayaudio.circle.fill
+airplayvideo
+airplayvideo.badge.exclamationmark
+airplayvideo.circle
+airplayvideo.circle.fill
+airpod.gen3.left
+airpod.gen3.right
+airpod.left
+airpod.right
+airpodpro.left
+airpodpro.right
+airpods
+airpods.chargingcase
+airpods.chargingcase.fill
+airpods.chargingcase.wireless
+airpods.chargingcase.wireless.fill
+airpods.gen3
+airpods.gen3.chargingcase.wireless
+airpods.gen3.chargingcase.wireless.fill
+airpods.gen4
+airpods.gen4.chargingcase.wireless
+airpods.gen4.chargingcase.wireless.fill
+airpods.gen4.left
+airpods.gen4.right
+airpods.max
+airpods.pro
+airpods.pro.chargingcase.wireless
+airpods.pro.chargingcase.wireless.fill
+airpods.pro.chargingcase.wireless.radiowaves.left.and.right
+airpods.pro.chargingcase.wireless.radiowaves.left.and.right.fill
+airpods.pro.left
+airpods.pro.right
+airpodsmax
+airpodspro
+airpodspro.chargingcase.wireless
+airpodspro.chargingcase.wireless.fill
+airpodspro.chargingcase.wireless.radiowaves.left.and.right
+airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill
+airport.express
+airport.extreme
+airport.extreme.tower
+airtag
+airtag.fill
+airtag.radiowaves.forward
+airtag.radiowaves.forward.fill
+alarm
+alarm.fill
+alarm.waves.left.and.right
+alarm.waves.left.and.right.fill
+align.horizontal.center
+align.horizontal.center.fill
+align.horizontal.left
+align.horizontal.left.fill
+align.horizontal.right
+align.horizontal.right.fill
+align.vertical.bottom
+align.vertical.bottom.fill
+align.vertical.center
+align.vertical.center.fill
+align.vertical.top
+align.vertical.top.fill
+allergens
+allergens.fill
+alt
+alternatingcurrent
+american.football
+american.football.circle
+american.football.circle.fill
+american.football.fill
+american.football.professional
+american.football.professional.circle
+american.football.professional.circle.fill
+american.football.professional.fill
+amplifier
+angle
+ant
+ant.circle
+ant.circle.fill
+ant.fill
+antenna.radiowaves.left.and.right
+antenna.radiowaves.left.and.right.circle
+antenna.radiowaves.left.and.right.circle.fill
+antenna.radiowaves.left.and.right.slash
+antenna.radiowaves.left.and.right.slash.circle
+antenna.radiowaves.left.and.right.slash.circle.fill
+app
+app.background.dotted
+app.badge
+app.badge.checkmark
+app.badge.checkmark.fill
+app.badge.clock
+app.badge.clock.fill
+app.badge.fill
+app.connected.to.app.below.fill
+app.dashed
+app.fill
+app.gift
+app.gift.fill
+app.grid
+app.shadow
+app.specular
+app.translucent
+appclip
+append.page
+append.page.fill
+apple.books.pages
+apple.books.pages.fill
+apple.classical.pages
+apple.classical.pages.fill
+apple.haptics.and.exclamationmark.triangle
+apple.haptics.and.music.note
+apple.haptics.and.music.note.slash
+apple.homekit
+apple.image.playground
+apple.image.playground.fill
+apple.intelligence
+apple.intelligence.badge.xmark
+apple.logo
+apple.meditate
+apple.meditate.circle
+apple.meditate.circle.fill
+apple.meditate.square.stack
+apple.meditate.square.stack.fill
+apple.podcasts.pages
+apple.podcasts.pages.fill
+apple.terminal
+apple.terminal.circle
+apple.terminal.circle.fill
+apple.terminal.fill
+apple.terminal.on.rectangle
+apple.terminal.on.rectangle.fill
+apple.writing.tools
+applelogo
+applepencil
+applepencil.adapter.usb.c
+applepencil.adapter.usb.c.fill
+applepencil.and.scribble
+applepencil.doubletap
+applepencil.gen1
+applepencil.gen2
+applepencil.hover
+applepencil.squeeze
+applepencil.tip
+applescript
+applescript.fill
+appletv
+appletv.badge.checkmark
+appletv.badge.checkmark.fill
+appletv.badge.exclamationmark
+appletv.badge.exclamationmark.fill
+appletv.fill
+appletvremote.gen1
+appletvremote.gen1.fill
+appletvremote.gen2
+appletvremote.gen2.fill
+appletvremote.gen3
+appletvremote.gen3.fill
+appletvremote.gen4
+appletvremote.gen4.fill
+applewatch
+applewatch.and.arrow.forward
+applewatch.badge.checkmark
+applewatch.badge.exclamationmark
+applewatch.case.inset.filled
+applewatch.case.sizes
+applewatch.radiowaves.left.and.right
+applewatch.side.right
+applewatch.slash
+applewatch.watchface
+apps.ipad
+apps.ipad.badge.checkmark
+apps.ipad.badge.plus
+apps.ipad.landscape
+apps.ipad.on.rectangle.portrait.dashed
+apps.iphone
+apps.iphone.badge.checkmark
+apps.iphone.badge.plus
+apps.iphone.landscape
+appwindow.swipe.rectangle
+aqi.high
+aqi.low
+aqi.medium
+aqi.medium.gauge.open
+arcade.stick
+arcade.stick.and.arrow.down
+arcade.stick.and.arrow.left
+arcade.stick.and.arrow.left.and.arrow.right
+arcade.stick.and.arrow.left.and.arrow.right.outward
+arcade.stick.and.arrow.right
+arcade.stick.and.arrow.up
+arcade.stick.and.arrow.up.and.arrow.down
+arcade.stick.console
+arcade.stick.console.fill
+archivebox
+archivebox.circle
+archivebox.circle.fill
+archivebox.fill
+arkit
+arkit.badge.xmark
+arrow.2.circlepath
+arrow.2.circlepath.circle
+arrow.2.circlepath.circle.fill
+arrow.2.squarepath
+arrow.3.trianglepath
+arrow.backward
+arrow.backward.circle
+arrow.backward.circle.dotted
+arrow.backward.circle.fill
+arrow.backward.square
+arrow.backward.square.fill
+arrow.backward.to.line
+arrow.backward.to.line.circle
+arrow.backward.to.line.circle.fill
+arrow.backward.to.line.compact
+arrow.backward.to.line.square
+arrow.backward.to.line.square.fill
+arrow.branch
+arrow.circlepath
+arrow.clockwise
+arrow.clockwise.circle
+arrow.clockwise.circle.fill
+arrow.clockwise.heart
+arrow.clockwise.heart.fill
+arrow.clockwise.icloud
+arrow.clockwise.icloud.fill
+arrow.clockwise.square
+arrow.clockwise.square.fill
+arrow.counterclockwise
+arrow.counterclockwise.circle
+arrow.counterclockwise.circle.fill
+arrow.counterclockwise.icloud
+arrow.counterclockwise.icloud.fill
+arrow.counterclockwise.square
+arrow.counterclockwise.square.fill
+arrow.down
+arrow.down.and.line.horizontal.and.arrow.up
+arrow.down.app
+arrow.down.app.dashed
+arrow.down.app.dashed.trianglebadge.exclamationmark
+arrow.down.app.fill
+arrow.down.applewatch
+arrow.down.backward
+arrow.down.backward.and.arrow.up.forward
+arrow.down.backward.and.arrow.up.forward.circle
+arrow.down.backward.and.arrow.up.forward.circle.fill
+arrow.down.backward.and.arrow.up.forward.rectangle
+arrow.down.backward.and.arrow.up.forward.rectangle.fill
+arrow.down.backward.and.arrow.up.forward.square
+arrow.down.backward.and.arrow.up.forward.square.fill
+arrow.down.backward.circle
+arrow.down.backward.circle.dotted
+arrow.down.backward.circle.fill
+arrow.down.backward.square
+arrow.down.backward.square.fill
+arrow.down.backward.toptrailing.rectangle
+arrow.down.backward.toptrailing.rectangle.fill
+arrow.down.circle
+arrow.down.circle.badge.pause
+arrow.down.circle.badge.pause.fill
+arrow.down.circle.badge.xmark
+arrow.down.circle.badge.xmark.fill
+arrow.down.circle.dotted
+arrow.down.circle.fill
+arrow.down.doc
+arrow.down.doc.fill
+arrow.down.document
+arrow.down.document.fill
+arrow.down.forward
+arrow.down.forward.and.arrow.up.backward
+arrow.down.forward.and.arrow.up.backward.circle
+arrow.down.forward.and.arrow.up.backward.circle.fill
+arrow.down.forward.and.arrow.up.backward.rectangle
+arrow.down.forward.and.arrow.up.backward.rectangle.fill
+arrow.down.forward.and.arrow.up.backward.square
+arrow.down.forward.and.arrow.up.backward.square.fill
+arrow.down.forward.circle
+arrow.down.forward.circle.dotted
+arrow.down.forward.circle.fill
+arrow.down.forward.square
+arrow.down.forward.square.fill
+arrow.down.forward.topleading.rectangle
+arrow.down.forward.topleading.rectangle.fill
+arrow.down.heart
+arrow.down.heart.fill
+arrow.down.left
+arrow.down.left.and.arrow.up.right
+arrow.down.left.and.arrow.up.right.circle
+arrow.down.left.and.arrow.up.right.circle.fill
+arrow.down.left.and.arrow.up.right.rectangle
+arrow.down.left.and.arrow.up.right.rectangle.fill
+arrow.down.left.and.arrow.up.right.square
+arrow.down.left.and.arrow.up.right.square.fill
+arrow.down.left.arrow.up.right
+arrow.down.left.arrow.up.right.circle
+arrow.down.left.arrow.up.right.circle.fill
+arrow.down.left.arrow.up.right.square
+arrow.down.left.arrow.up.right.square.fill
+arrow.down.left.circle
+arrow.down.left.circle.dotted
+arrow.down.left.circle.fill
+arrow.down.left.square
+arrow.down.left.square.fill
+arrow.down.left.topright.rectangle
+arrow.down.left.topright.rectangle.fill
+arrow.down.left.video
+arrow.down.left.video.fill
+arrow.down.message
+arrow.down.message.fill
+arrow.down.right
+arrow.down.right.and.arrow.up.left
+arrow.down.right.and.arrow.up.left.circle
+arrow.down.right.and.arrow.up.left.circle.fill
+arrow.down.right.and.arrow.up.left.rectangle
+arrow.down.right.and.arrow.up.left.rectangle.fill
+arrow.down.right.and.arrow.up.left.square
+arrow.down.right.and.arrow.up.left.square.fill
+arrow.down.right.circle
+arrow.down.right.circle.dotted
+arrow.down.right.circle.fill
+arrow.down.right.square
+arrow.down.right.square.fill
+arrow.down.right.topleft.rectangle
+arrow.down.right.topleft.rectangle.fill
+arrow.down.square
+arrow.down.square.fill
+arrow.down.to.line
+arrow.down.to.line.alt
+arrow.down.to.line.circle
+arrow.down.to.line.circle.fill
+arrow.down.to.line.compact
+arrow.down.to.line.square
+arrow.down.to.line.square.fill
+arrow.forward
+arrow.forward.circle
+arrow.forward.circle.dotted
+arrow.forward.circle.fill
+arrow.forward.folder
+arrow.forward.folder.fill
+arrow.forward.square
+arrow.forward.square.fill
+arrow.forward.to.line
+arrow.forward.to.line.circle
+arrow.forward.to.line.circle.fill
+arrow.forward.to.line.compact
+arrow.forward.to.line.square
+arrow.forward.to.line.square.fill
+arrow.left
+arrow.left.and.line.vertical.and.arrow.right
+arrow.left.and.right
+arrow.left.and.right.circle
+arrow.left.and.right.circle.fill
+arrow.left.and.right.righttriangle.left.righttriangle.right
+arrow.left.and.right.righttriangle.left.righttriangle.right.fill
+arrow.left.and.right.square
+arrow.left.and.right.square.fill
+arrow.left.and.right.text.vertical
+arrow.left.arrow.right
+arrow.left.arrow.right.circle
+arrow.left.arrow.right.circle.fill
+arrow.left.arrow.right.square
+arrow.left.arrow.right.square.fill
+arrow.left.circle
+arrow.left.circle.dotted
+arrow.left.circle.fill
+arrow.left.square
+arrow.left.square.fill
+arrow.left.to.line
+arrow.left.to.line.alt
+arrow.left.to.line.circle
+arrow.left.to.line.circle.fill
+arrow.left.to.line.compact
+arrow.left.to.line.square
+arrow.left.to.line.square.fill
+arrow.merge
+arrow.rectanglepath
+arrow.right
+arrow.right.and.line.vertical.and.arrow.left
+arrow.right.arrow.left
+arrow.right.arrow.left.circle
+arrow.right.arrow.left.circle.fill
+arrow.right.arrow.left.square
+arrow.right.arrow.left.square.fill
+arrow.right.circle
+arrow.right.circle.dotted
+arrow.right.circle.fill
+arrow.right.doc.on.clipboard
+arrow.right.filled.filter.arrow.right
+arrow.right.page.on.clipboard
+arrow.right.square
+arrow.right.square.fill
+arrow.right.to.line
+arrow.right.to.line.alt
+arrow.right.to.line.circle
+arrow.right.to.line.circle.fill
+arrow.right.to.line.compact
+arrow.right.to.line.square
+arrow.right.to.line.square.fill
+arrow.swap
+arrow.triangle.2.circlepath
+arrow.triangle.2.circlepath.camera
+arrow.triangle.2.circlepath.camera.fill
+arrow.triangle.2.circlepath.circle
+arrow.triangle.2.circlepath.circle.fill
+arrow.triangle.2.circlepath.doc.on.clipboard
+arrow.triangle.2.circlepath.icloud
+arrow.triangle.2.circlepath.icloud.fill
+arrow.triangle.branch
+arrow.triangle.capsulepath
+arrow.triangle.merge
+arrow.triangle.pull
+arrow.triangle.swap
+arrow.triangle.turn.up.right.circle
+arrow.triangle.turn.up.right.circle.fill
+arrow.triangle.turn.up.right.diamond
+arrow.triangle.turn.up.right.diamond.fill
+arrow.trianglehead.2.clockwise
+arrow.trianglehead.2.clockwise.rotate.90
+arrow.trianglehead.2.clockwise.rotate.90.camera
+arrow.trianglehead.2.clockwise.rotate.90.camera.fill
+arrow.trianglehead.2.clockwise.rotate.90.circle
+arrow.trianglehead.2.clockwise.rotate.90.circle.fill
+arrow.trianglehead.2.clockwise.rotate.90.icloud
+arrow.trianglehead.2.clockwise.rotate.90.icloud.fill
+arrow.trianglehead.2.clockwise.rotate.90.page.on.clipboard
+arrow.trianglehead.2.counterclockwise
+arrow.trianglehead.2.counterclockwise.rotate.90
+arrow.trianglehead.bottomleft.capsulepath.clockwise
+arrow.trianglehead.branch
+arrow.trianglehead.clockwise
+arrow.trianglehead.clockwise.heart
+arrow.trianglehead.clockwise.heart.fill
+arrow.trianglehead.clockwise.icloud
+arrow.trianglehead.clockwise.icloud.fill
+arrow.trianglehead.clockwise.rotate.90
+arrow.trianglehead.counterclockwise
+arrow.trianglehead.counterclockwise.icloud
+arrow.trianglehead.counterclockwise.icloud.fill
+arrow.trianglehead.counterclockwise.rotate.90
+arrow.trianglehead.left.and.right.righttriangle.left.righttriangle.right
+arrow.trianglehead.left.and.right.righttriangle.left.righttriangle.right.fill
+arrow.trianglehead.merge
+arrow.trianglehead.pull
+arrow.trianglehead.rectanglepath
+arrow.trianglehead.swap
+arrow.trianglehead.topright.capsulepath.clockwise
+arrow.trianglehead.turn.up.right
+arrow.trianglehead.turn.up.right.circle
+arrow.trianglehead.turn.up.right.circle.fill
+arrow.trianglehead.turn.up.right.diamond
+arrow.trianglehead.turn.up.right.diamond.fill
+arrow.trianglehead.up.and.down.righttriangle.up.righttriangle.down
+arrow.trianglehead.up.and.down.righttriangle.up.righttriangle.down.fill
+arrow.turn.down.left
+arrow.turn.down.right
+arrow.turn.left.down
+arrow.turn.left.up
+arrow.turn.right.down
+arrow.turn.right.up
+arrow.turn.up.forward.iphone
+arrow.turn.up.forward.iphone.fill
+arrow.turn.up.left
+arrow.turn.up.right
+arrow.up
+arrow.up.and.down
+arrow.up.and.down.and.arrow.left.and.right
+arrow.up.and.down.and.sparkles
+arrow.up.and.down.circle
+arrow.up.and.down.circle.fill
+arrow.up.and.down.righttriangle.up.fill.righttriangle.down.fill
+arrow.up.and.down.righttriangle.up.righttriangle.down
+arrow.up.and.down.righttriangle.up.righttriangle.down.fill
+arrow.up.and.down.square
+arrow.up.and.down.square.fill
+arrow.up.and.down.text.horizontal
+arrow.up.and.line.horizontal.and.arrow.down
+arrow.up.and.person.rectangle.portrait
+arrow.up.and.person.rectangle.turn.left
+arrow.up.and.person.rectangle.turn.right
+arrow.up.arrow.down
+arrow.up.arrow.down.circle
+arrow.up.arrow.down.circle.fill
+arrow.up.arrow.down.square
+arrow.up.arrow.down.square.fill
+arrow.up.backward
+arrow.up.backward.and.arrow.down.forward
+arrow.up.backward.and.arrow.down.forward.circle
+arrow.up.backward.and.arrow.down.forward.circle.fill
+arrow.up.backward.and.arrow.down.forward.rectangle
+arrow.up.backward.and.arrow.down.forward.rectangle.fill
+arrow.up.backward.and.arrow.down.forward.square
+arrow.up.backward.and.arrow.down.forward.square.fill
+arrow.up.backward.bottomtrailing.rectangle
+arrow.up.backward.bottomtrailing.rectangle.fill
+arrow.up.backward.circle
+arrow.up.backward.circle.dotted
+arrow.up.backward.circle.fill
+arrow.up.backward.square
+arrow.up.backward.square.fill
+arrow.up.bin
+arrow.up.bin.fill
+arrow.up.circle
+arrow.up.circle.badge.clock
+arrow.up.circle.dotted
+arrow.up.circle.fill
+arrow.up.doc
+arrow.up.doc.fill
+arrow.up.doc.on.clipboard
+arrow.up.document
+arrow.up.document.fill
+arrow.up.folder
+arrow.up.folder.fill
+arrow.up.forward
+arrow.up.forward.and.arrow.down.backward
+arrow.up.forward.and.arrow.down.backward.circle
+arrow.up.forward.and.arrow.down.backward.circle.fill
+arrow.up.forward.and.arrow.down.backward.rectangle
+arrow.up.forward.and.arrow.down.backward.rectangle.fill
+arrow.up.forward.and.arrow.down.backward.square
+arrow.up.forward.and.arrow.down.backward.square.fill
+arrow.up.forward.app
+arrow.up.forward.app.fill
+arrow.up.forward.bottomleading.rectangle
+arrow.up.forward.bottomleading.rectangle.fill
+arrow.up.forward.circle
+arrow.up.forward.circle.dotted
+arrow.up.forward.circle.fill
+arrow.up.forward.square
+arrow.up.forward.square.fill
+arrow.up.heart
+arrow.up.heart.fill
+arrow.up.left
+arrow.up.left.and.arrow.down.right
+arrow.up.left.and.arrow.down.right.circle
+arrow.up.left.and.arrow.down.right.circle.fill
+arrow.up.left.and.arrow.down.right.rectangle
+arrow.up.left.and.arrow.down.right.rectangle.fill
+arrow.up.left.and.arrow.down.right.square
+arrow.up.left.and.arrow.down.right.square.fill
+arrow.up.left.and.down.right.and.arrow.up.right.and.down.left
+arrow.up.left.and.down.right.magnifyingglass
+arrow.up.left.arrow.down.right
+arrow.up.left.arrow.down.right.circle
+arrow.up.left.arrow.down.right.circle.fill
+arrow.up.left.arrow.down.right.square
+arrow.up.left.arrow.down.right.square.fill
+arrow.up.left.bottomright.rectangle
+arrow.up.left.bottomright.rectangle.fill
+arrow.up.left.circle
+arrow.up.left.circle.dotted
+arrow.up.left.circle.fill
+arrow.up.left.square
+arrow.up.left.square.fill
+arrow.up.message
+arrow.up.message.fill
+arrow.up.page.on.clipboard
+arrow.up.right
+arrow.up.right.and.arrow.down.left
+arrow.up.right.and.arrow.down.left.circle
+arrow.up.right.and.arrow.down.left.circle.fill
+arrow.up.right.and.arrow.down.left.rectangle
+arrow.up.right.and.arrow.down.left.rectangle.fill
+arrow.up.right.and.arrow.down.left.square
+arrow.up.right.and.arrow.down.left.square.fill
+arrow.up.right.bottomleft.rectangle
+arrow.up.right.bottomleft.rectangle.fill
+arrow.up.right.circle
+arrow.up.right.circle.dotted
+arrow.up.right.circle.fill
+arrow.up.right.diamond
+arrow.up.right.diamond.fill
+arrow.up.right.square
+arrow.up.right.square.fill
+arrow.up.right.video
+arrow.up.right.video.fill
+arrow.up.square
+arrow.up.square.fill
+arrow.up.to.line
+arrow.up.to.line.alt
+arrow.up.to.line.circle
+arrow.up.to.line.circle.fill
+arrow.up.to.line.compact
+arrow.up.to.line.square
+arrow.up.to.line.square.fill
+arrow.up.trash
+arrow.up.trash.fill
+arrow.uturn.backward
+arrow.uturn.backward.circle
+arrow.uturn.backward.circle.badge.ellipsis
+arrow.uturn.backward.circle.fill
+arrow.uturn.backward.square
+arrow.uturn.backward.square.fill
+arrow.uturn.down
+arrow.uturn.down.circle
+arrow.uturn.down.circle.fill
+arrow.uturn.down.square
+arrow.uturn.down.square.fill
+arrow.uturn.forward
+arrow.uturn.forward.circle
+arrow.uturn.forward.circle.fill
+arrow.uturn.forward.square
+arrow.uturn.forward.square.fill
+arrow.uturn.left
+arrow.uturn.left.circle
+arrow.uturn.left.circle.badge.ellipsis
+arrow.uturn.left.circle.fill
+arrow.uturn.left.square
+arrow.uturn.left.square.fill
+arrow.uturn.right
+arrow.uturn.right.circle
+arrow.uturn.right.circle.fill
+arrow.uturn.right.square
+arrow.uturn.right.square.fill
+arrow.uturn.up
+arrow.uturn.up.circle
+arrow.uturn.up.circle.fill
+arrow.uturn.up.square
+arrow.uturn.up.square.fill
+arrowkeys
+arrowkeys.down.filled
+arrowkeys.fill
+arrowkeys.left.filled
+arrowkeys.right.filled
+arrowkeys.up.filled
+arrowshape.backward
+arrowshape.backward.circle
+arrowshape.backward.circle.fill
+arrowshape.backward.fill
+arrowshape.bounce.forward
+arrowshape.bounce.forward.fill
+arrowshape.bounce.right
+arrowshape.bounce.right.fill
+arrowshape.down
+arrowshape.down.circle
+arrowshape.down.circle.fill
+arrowshape.down.fill
+arrowshape.forward
+arrowshape.forward.circle
+arrowshape.forward.circle.fill
+arrowshape.forward.fill
+arrowshape.left
+arrowshape.left.arrowshape.right
+arrowshape.left.arrowshape.right.fill
+arrowshape.left.circle
+arrowshape.left.circle.fill
+arrowshape.left.fill
+arrowshape.right
+arrowshape.right.circle
+arrowshape.right.circle.fill
+arrowshape.right.fill
+arrowshape.turn.up.backward
+arrowshape.turn.up.backward.2
+arrowshape.turn.up.backward.2.circle
+arrowshape.turn.up.backward.2.circle.fill
+arrowshape.turn.up.backward.2.fill
+arrowshape.turn.up.backward.badge.clock
+arrowshape.turn.up.backward.badge.clock.fill
+arrowshape.turn.up.backward.circle
+arrowshape.turn.up.backward.circle.fill
+arrowshape.turn.up.backward.fill
+arrowshape.turn.up.forward
+arrowshape.turn.up.forward.circle
+arrowshape.turn.up.forward.circle.fill
+arrowshape.turn.up.forward.fill
+arrowshape.turn.up.left
+arrowshape.turn.up.left.2
+arrowshape.turn.up.left.2.circle
+arrowshape.turn.up.left.2.circle.fill
+arrowshape.turn.up.left.2.fill
+arrowshape.turn.up.left.circle
+arrowshape.turn.up.left.circle.fill
+arrowshape.turn.up.left.fill
+arrowshape.turn.up.right
+arrowshape.turn.up.right.circle
+arrowshape.turn.up.right.circle.fill
+arrowshape.turn.up.right.fill
+arrowshape.up
+arrowshape.up.circle
+arrowshape.up.circle.fill
+arrowshape.up.fill
+arrowshape.zigzag.forward
+arrowshape.zigzag.forward.fill
+arrowshape.zigzag.right
+arrowshape.zigzag.right.fill
+arrowtriangle.backward
+arrowtriangle.backward.circle
+arrowtriangle.backward.circle.fill
+arrowtriangle.backward.fill
+arrowtriangle.backward.inset.filled.trailingthird.rectangle
+arrowtriangle.backward.square
+arrowtriangle.backward.square.fill
+arrowtriangle.down
+arrowtriangle.down.2
+arrowtriangle.down.2.fill
+arrowtriangle.down.circle
+arrowtriangle.down.circle.fill
+arrowtriangle.down.fill
+arrowtriangle.down.square
+arrowtriangle.down.square.fill
+arrowtriangle.forward
+arrowtriangle.forward.circle
+arrowtriangle.forward.circle.fill
+arrowtriangle.forward.fill
+arrowtriangle.forward.inset.filled.trailingthird.rectangle
+arrowtriangle.forward.square
+arrowtriangle.forward.square.fill
+arrowtriangle.left
+arrowtriangle.left.and.line.vertical.and.arrowtriangle.right
+arrowtriangle.left.and.line.vertical.and.arrowtriangle.right.fill
+arrowtriangle.left.circle
+arrowtriangle.left.circle.fill
+arrowtriangle.left.fill
+arrowtriangle.left.fill.and.line.vertical.and.arrowtriangle.right.fill
+arrowtriangle.left.square
+arrowtriangle.left.square.fill
+arrowtriangle.right
+arrowtriangle.right.and.line.vertical.and.arrowtriangle.left
+arrowtriangle.right.and.line.vertical.and.arrowtriangle.left.fill
+arrowtriangle.right.circle
+arrowtriangle.right.circle.fill
+arrowtriangle.right.fill
+arrowtriangle.right.fill.and.line.vertical.and.arrowtriangle.left.fill
+arrowtriangle.right.square
+arrowtriangle.right.square.fill
+arrowtriangle.up
+arrowtriangle.up.2
+arrowtriangle.up.2.fill
+arrowtriangle.up.arrowtriangle.down.window.left
+arrowtriangle.up.arrowtriangle.down.window.right
+arrowtriangle.up.circle
+arrowtriangle.up.circle.fill
+arrowtriangle.up.fill
+arrowtriangle.up.square
+arrowtriangle.up.square.fill
+aspectratio
+aspectratio.fill
+asterisk
+asterisk.circle
+asterisk.circle.fill
+at
+at.badge.minus
+at.badge.plus
+at.circle
+at.circle.fill
+atom
+audio.jack.mono
+audio.jack.stereo
+australian.football
+australian.football.circle
+australian.football.circle.fill
+australian.football.fill
+australiandollarsign
+australiandollarsign.arrow.circlepath
+australiandollarsign.arrow.trianglehead.counterclockwise.rotate.90
+australiandollarsign.bank.building
+australiandollarsign.bank.building.fill
+australiandollarsign.circle
+australiandollarsign.circle.fill
+australiandollarsign.gauge.chart.lefthalf.righthalf
+australiandollarsign.gauge.chart.leftthird.topthird.rightthird
+australiandollarsign.ring
+australiandollarsign.ring.dashed
+australiandollarsign.square
+australiandollarsign.square.fill
+australsign
+australsign.arrow.circlepath
+australsign.arrow.trianglehead.counterclockwise.rotate.90
+australsign.bank.building
+australsign.bank.building.fill
+australsign.circle
+australsign.circle.fill
+australsign.gauge.chart.lefthalf.righthalf
+australsign.gauge.chart.leftthird.topthird.rightthird
+australsign.ring
+australsign.ring.dashed
+australsign.square
+australsign.square.fill
+auto.brakesignal
+auto.headlight.high.beam
+auto.headlight.high.beam.fill
+auto.headlight.low.beam
+auto.headlight.low.beam.fill
+automatic.brakesignal
+automatic.headlight.high.beam
+automatic.headlight.high.beam.fill
+automatic.headlight.low.beam
+automatic.headlight.low.beam.fill
+autostartstop
+autostartstop.slash
+autostartstop.trianglebadge.exclamationmark
+av.remote
+av.remote.fill
+axel.2
+axel.2.front.and.rear.engaged
+axel.2.front.engaged
+axel.2.rear.engaged
+axle.2
+axle.2.driveshaft.disengaged
+axle.2.front.and.rear.engaged
+axle.2.front.disengaged
+axle.2.front.engaged
+axle.2.rear.disengaged
+axle.2.rear.engaged
+axle.2.rear.lock
+b.circle
+b.circle.fill
+b.square
+b.square.fill
+backpack
+backpack.circle
+backpack.circle.fill
+backpack.fill
+backpack.sensor.tag.radiowaves.left.and.right
+backpack.sensor.tag.radiowaves.left.and.right.fill
+backward
+backward.circle
+backward.circle.fill
+backward.end
+backward.end.alt
+backward.end.alt.fill
+backward.end.circle
+backward.end.circle.fill
+backward.end.fill
+backward.fill
+backward.frame
+backward.frame.fill
+badge.plus.radiowaves.forward
+badge.plus.radiowaves.right
+bag
+bag.badge.minus
+bag.badge.plus
+bag.badge.questionmark
+bag.circle
+bag.circle.fill
+bag.fill
+bag.fill.badge.minus
+bag.fill.badge.plus
+bag.fill.badge.questionmark
+bahtsign
+bahtsign.arrow.circlepath
+bahtsign.arrow.trianglehead.counterclockwise.rotate.90
+bahtsign.bank.building
+bahtsign.bank.building.fill
+bahtsign.circle
+bahtsign.circle.fill
+bahtsign.gauge.chart.lefthalf.righthalf
+bahtsign.gauge.chart.leftthird.topthird.rightthird
+bahtsign.ring
+bahtsign.ring.dashed
+bahtsign.square
+bahtsign.square.fill
+balloon
+balloon.2
+balloon.2.fill
+balloon.fill
+bandage
+bandage.fill
+banknote
+banknote.fill
+barcode
+barcode.viewfinder
+barometer
+base.unit
+baseball
+baseball.circle
+baseball.circle.fill
+baseball.diamond.bases
+baseball.diamond.bases.outs.indicator
+baseball.fill
+basket
+basket.fill
+basketball
+basketball.circle
+basketball.circle.fill
+basketball.fill
+bathtub
+bathtub.fill
+battery.0
+battery.0percent
+battery.100
+battery.100.bolt
+battery.100.circle
+battery.100.circle.fill
+battery.100percent
+battery.100percent.bolt
+battery.100percent.circle
+battery.100percent.circle.fill
+battery.25
+battery.25percent
+battery.50
+battery.50percent
+battery.75
+battery.75percent
+batteryblock
+batteryblock.fill
+batteryblock.slash
+batteryblock.slash.fill
+batteryblock.stack
+batteryblock.stack.badge.snowflake
+batteryblock.stack.badge.snowflake.fill
+batteryblock.stack.fill
+batteryblock.stack.trianglebadge.exclamationmark
+batteryblock.stack.trianglebadge.exclamationmark.fill
+beach.umbrella
+beach.umbrella.fill
+beats.earphones
+beats.fit.pro
+beats.fit.pro.chargingcase
+beats.fit.pro.chargingcase.fill
+beats.fit.pro.left
+beats.fit.pro.right
+beats.fitpro
+beats.fitpro.chargingcase
+beats.fitpro.chargingcase.fill
+beats.fitpro.left
+beats.fitpro.right
+beats.headphones
+beats.pill
+beats.pill.fill
+beats.powerbeats
+beats.powerbeats.left
+beats.powerbeats.pro
+beats.powerbeats.pro.2
+beats.powerbeats.pro.2.chargingcase
+beats.powerbeats.pro.2.chargingcase.fill
+beats.powerbeats.pro.2.left
+beats.powerbeats.pro.2.right
+beats.powerbeats.pro.chargingcase
+beats.powerbeats.pro.chargingcase.fill
+beats.powerbeats.pro.left
+beats.powerbeats.pro.right
+beats.powerbeats.right
+beats.powerbeats3
+beats.powerbeats3.left
+beats.powerbeats3.right
+beats.powerbeatspro
+beats.powerbeatspro.chargingcase
+beats.powerbeatspro.chargingcase.fill
+beats.powerbeatspro.left
+beats.powerbeatspro.right
+beats.solobuds
+beats.solobuds.chargingcase
+beats.solobuds.chargingcase.fill
+beats.solobuds.left
+beats.solobuds.right
+beats.studiobud.left
+beats.studiobud.right
+beats.studiobuds
+beats.studiobuds.chargingcase
+beats.studiobuds.chargingcase.fill
+beats.studiobuds.left
+beats.studiobuds.plus
+beats.studiobuds.plus.chargingcase
+beats.studiobuds.plus.chargingcase.fill
+beats.studiobuds.plus.left
+beats.studiobuds.plus.right
+beats.studiobuds.right
+beats.studiobudsplus
+beats.studiobudsplus.chargingcase
+beats.studiobudsplus.chargingcase.fill
+beats.studiobudsplus.left
+beats.studiobudsplus.right
+bed.double
+bed.double.badge.checkmark
+bed.double.badge.checkmark.fill
+bed.double.circle
+bed.double.circle.fill
+bed.double.fill
+bell
+bell.and.waveform
+bell.and.waveform.fill
+bell.and.waves.left.and.right
+bell.and.waves.left.and.right.fill
+bell.badge
+bell.badge.circle
+bell.badge.circle.fill
+bell.badge.fill
+bell.badge.slash
+bell.badge.slash.fill
+bell.badge.waveform
+bell.badge.waveform.fill
+bell.badge.waveform.slash
+bell.badge.waveform.slash.fill
+bell.circle
+bell.circle.fill
+bell.fill
+bell.slash
+bell.slash.circle
+bell.slash.circle.fill
+bell.slash.fill
+bell.square
+bell.square.fill
+beziercurve
+bicycle
+bicycle.circle
+bicycle.circle.fill
+bicycle.sensor.tag.radiowaves.left.and.right
+bicycle.sensor.tag.radiowaves.left.and.right.fill
+bin.xmark
+bin.xmark.fill
+binoculars
+binoculars.circle
+binoculars.circle.fill
+binoculars.fill
+bird
+bird.circle
+bird.circle.fill
+bird.fill
+birthday.cake
+birthday.cake.fill
+bitcoinsign
+bitcoinsign.arrow.circlepath
+bitcoinsign.arrow.trianglehead.counterclockwise.rotate.90
+bitcoinsign.bank.building
+bitcoinsign.bank.building.fill
+bitcoinsign.circle
+bitcoinsign.circle.fill
+bitcoinsign.gauge.chart.lefthalf.righthalf
+bitcoinsign.gauge.chart.leftthird.topthird.rightthird
+bitcoinsign.ring
+bitcoinsign.ring.dashed
+bitcoinsign.square
+bitcoinsign.square.fill
+blinds.horizontal.closed
+blinds.horizontal.open
+blinds.vertical.closed
+blinds.vertical.open
+blood.pressure.cuff
+blood.pressure.cuff.badge.gauge.with.needle
+blood.pressure.cuff.badge.gauge.with.needle.fill
+blood.pressure.cuff.fill
+bold
+bold.italic.underline
+bold.underline
+bolt
+bolt.badge.a
+bolt.badge.a.fill
+bolt.badge.automatic
+bolt.badge.automatic.fill
+bolt.badge.checkmark
+bolt.badge.checkmark.fill
+bolt.badge.clock
+bolt.badge.clock.fill
+bolt.badge.xmark
+bolt.badge.xmark.fill
+bolt.batteryblock
+bolt.batteryblock.fill
+bolt.brakesignal
+bolt.car
+bolt.car.circle
+bolt.car.circle.fill
+bolt.car.fill
+bolt.circle
+bolt.circle.fill
+bolt.fill
+bolt.fill.batteryblock
+bolt.fill.batteryblock.fill
+bolt.heart
+bolt.heart.fill
+bolt.horizontal
+bolt.horizontal.circle
+bolt.horizontal.circle.fill
+bolt.horizontal.fill
+bolt.horizontal.icloud
+bolt.horizontal.icloud.fill
+bolt.house
+bolt.house.fill
+bolt.ring.closed
+bolt.shield
+bolt.shield.fill
+bolt.slash
+bolt.slash.circle
+bolt.slash.circle.fill
+bolt.slash.fill
+bolt.square
+bolt.square.fill
+bolt.trianglebadge.exclamationmark
+bolt.trianglebadge.exclamationmark.fill
+bonjour
+book
+book.and.wrench
+book.and.wrench.fill
+book.badge.plus
+book.badge.plus.fill
+book.circle
+book.circle.fill
+book.closed
+book.closed.circle
+book.closed.circle.fill
+book.closed.fill
+book.fill
+book.pages
+book.pages.fill
+bookmark
+bookmark.circle
+bookmark.circle.fill
+bookmark.fill
+bookmark.slash
+bookmark.slash.fill
+bookmark.square
+bookmark.square.fill
+books.vertical
+books.vertical.circle
+books.vertical.circle.fill
+books.vertical.fill
+box.truck
+box.truck.badge.clock
+box.truck.badge.clock.fill
+box.truck.fill
+brain
+brain.fill
+brain.filled.head.profile
+brain.head.profile
+brain.head.profile.fill
+brakesignal
+brakesignal.dashed
+brazilianrealsign
+brazilianrealsign.arrow.circlepath
+brazilianrealsign.arrow.trianglehead.counterclockwise.rotate.90
+brazilianrealsign.bank.building
+brazilianrealsign.bank.building.fill
+brazilianrealsign.circle
+brazilianrealsign.circle.fill
+brazilianrealsign.gauge.chart.lefthalf.righthalf
+brazilianrealsign.gauge.chart.leftthird.topthird.rightthird
+brazilianrealsign.ring
+brazilianrealsign.ring.dashed
+brazilianrealsign.square
+brazilianrealsign.square.fill
+briefcase
+briefcase.circle
+briefcase.circle.fill
+briefcase.fill
+briefcase.sensor.tag.radiowaves.left.and.right
+briefcase.sensor.tag.radiowaves.left.and.right.fill
+bubble
+bubble.and.pencil
+bubble.circle
+bubble.circle.fill
+bubble.fill
+bubble.left
+bubble.left.and.bubble.right
+bubble.left.and.bubble.right.fill
+bubble.left.and.exclamationmark.bubble.right
+bubble.left.and.exclamationmark.bubble.right.fill
+bubble.left.and.text.bubble.right
+bubble.left.and.text.bubble.right.fill
+bubble.left.circle
+bubble.left.circle.fill
+bubble.left.fill
+bubble.middle.bottom
+bubble.middle.bottom.fill
+bubble.middle.top
+bubble.middle.top.fill
+bubble.right
+bubble.right.circle
+bubble.right.circle.fill
+bubble.right.fill
+bubbles.and.sparkles
+bubbles.and.sparkles.fill
+building
+building.2
+building.2.crop.circle
+building.2.crop.circle.fill
+building.2.fill
+building.columns
+building.columns.circle
+building.columns.circle.fill
+building.columns.fill
+building.fill
+burn
+burst
+burst.fill
+bus
+bus.doubledecker
+bus.doubledecker.fill
+bus.fill
+button.angledbottom.horizontal.left
+button.angledbottom.horizontal.left.fill
+button.angledbottom.horizontal.right
+button.angledbottom.horizontal.right.fill
+button.angledtop.vertical.left
+button.angledtop.vertical.left.fill
+button.angledtop.vertical.right
+button.angledtop.vertical.right.fill
+button.horizontal
+button.horizontal.fill
+button.horizontal.top
+button.horizontal.top.fill
+button.horizontal.top.press
+button.horizontal.top.press.fill
+button.programmable
+button.programmable.square
+button.programmable.square.fill
+button.roundedbottom.horizontal
+button.roundedbottom.horizontal.fill
+button.roundedtop.horizontal
+button.roundedtop.horizontal.fill
+button.vertical.left
+button.vertical.left.fill
+button.vertical.left.press
+button.vertical.left.press.fill
+button.vertical.right
+button.vertical.right.fill
+button.vertical.right.press
+button.vertical.right.press.fill
+c.circle
+c.circle.fill
+c.square
+c.square.fill
+cabinet
+cabinet.fill
+cable.coaxial
+cable.connector
+cable.connector.horizontal
+cable.connector.slash
+cable.connector.video
+cablecar
+cablecar.fill
+calendar
+calendar.and.person
+calendar.badge
+calendar.badge.checkmark
+calendar.badge.clock
+calendar.badge.exclamationmark
+calendar.badge.lock
+calendar.badge.minus
+calendar.badge.plus
+calendar.circle
+calendar.circle.fill
+calendar.day.timeline.leading
+calendar.day.timeline.leading.circle
+calendar.day.timeline.leading.circle.fill
+calendar.day.timeline.left
+calendar.day.timeline.left.circle
+calendar.day.timeline.left.circle.fill
+calendar.day.timeline.right
+calendar.day.timeline.right.circle
+calendar.day.timeline.right.circle.fill
+calendar.day.timeline.trailing
+calendar.day.timeline.trailing.circle
+calendar.day.timeline.trailing.circle.fill
+camera
+camera.aperture
+camera.badge.clock
+camera.badge.clock.fill
+camera.badge.ellipsis
+camera.badge.ellipsis.fill
+camera.circle
+camera.circle.fill
+camera.fill
+camera.fill.badge.ellipsis
+camera.filters
+camera.macro
+camera.macro.circle
+camera.macro.circle.fill
+camera.macro.slash
+camera.macro.slash.circle
+camera.macro.slash.circle.fill
+camera.metering.center.weighted
+camera.metering.center.weighted.average
+camera.metering.matrix
+camera.metering.multispot
+camera.metering.none
+camera.metering.partial
+camera.metering.spot
+camera.metering.unknown
+camera.on.rectangle
+camera.on.rectangle.fill
+camera.rotate
+camera.rotate.fill
+camera.sensor.tag.radiowaves.left.and.right
+camera.sensor.tag.radiowaves.left.and.right.fill
+camera.shutter.button
+camera.shutter.button.fill
+camera.viewfinder
+camera.viewfinder.badge.automatic
+candybarphone
+capslock
+capslock.fill
+capsule
+capsule.bottomhalf.filled
+capsule.fill
+capsule.inset.filled
+capsule.lefthalf.filled
+capsule.on.capsule
+capsule.on.capsule.fill
+capsule.on.rectangle
+capsule.on.rectangle.fill
+capsule.portrait
+capsule.portrait.bottomhalf.filled
+capsule.portrait.fill
+capsule.portrait.inset.filled
+capsule.portrait.lefthalf.filled
+capsule.portrait.righthalf.filled
+capsule.portrait.tophalf.filled
+capsule.righthalf.filled
+capsule.tophalf.filled
+captions.bubble
+captions.bubble.fill
+car
+car.2
+car.2.fill
+car.badge.gearshape
+car.badge.gearshape.fill
+car.circle
+car.circle.fill
+car.ferry
+car.ferry.fill
+car.fill
+car.front.waves.down
+car.front.waves.down.fill
+car.front.waves.left.and.right.and.up
+car.front.waves.left.and.right.and.up.fill
+car.front.waves.up
+car.front.waves.up.fill
+car.rear
+car.rear.and.collision.road.lane
+car.rear.and.collision.road.lane.slash
+car.rear.and.tire.marks
+car.rear.and.tire.marks.off
+car.rear.and.tire.marks.slash
+car.rear.fill
+car.rear.hazardsign
+car.rear.hazardsign.fill
+car.rear.road.lane
+car.rear.road.lane.dashed
+car.rear.road.lane.dashed.arrowtriangle.2.outward
+car.rear.road.lane.distance.1
+car.rear.road.lane.distance.1.and.gauge.open.with.lines.needle.67percent.and.arrowtriangle
+car.rear.road.lane.distance.2
+car.rear.road.lane.distance.2.and.gauge.open.with.lines.needle.67percent.and.arrowtriangle
+car.rear.road.lane.distance.3
+car.rear.road.lane.distance.3.and.gauge.open.with.lines.needle.67percent.and.arrowtriangle
+car.rear.road.lane.distance.4
+car.rear.road.lane.distance.4.and.gauge.open.with.lines.needle.67percent.and.arrowtriangle
+car.rear.road.lane.distance.5
+car.rear.road.lane.distance.5.and.gauge.open.with.lines.needle.67percent.and.arrowtriangle
+car.rear.road.lane.off
+car.rear.road.lane.wave.up
+car.rear.tilt.road.lanes.curved.right
+car.rear.waves.up
+car.rear.waves.up.fill
+car.side
+car.side.air.circulate
+car.side.air.circulate.fill
+car.side.air.fresh
+car.side.air.fresh.fill
+car.side.and.exclamationmark
+car.side.and.exclamationmark.fill
+car.side.arrow.left.and.right
+car.side.arrow.left.and.right.fill
+car.side.arrowtriangle.down
+car.side.arrowtriangle.down.fill
+car.side.arrowtriangle.up
+car.side.arrowtriangle.up.arrowtriangle.down
+car.side.arrowtriangle.up.arrowtriangle.down.fill
+car.side.arrowtriangle.up.fill
+car.side.fill
+car.side.front.open
+car.side.front.open.crop
+car.side.front.open.crop.fill
+car.side.front.open.fill
+car.side.hill.descent.control
+car.side.hill.descent.control.fill
+car.side.hill.down
+car.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle
+car.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle.fill
+car.side.hill.down.fill
+car.side.hill.up
+car.side.hill.up.fill
+car.side.lock
+car.side.lock.fill
+car.side.lock.open
+car.side.lock.open.fill
+car.side.rear.and.collision.and.car.side.front
+car.side.rear.and.collision.and.car.side.front.and.arrow.forward
+car.side.rear.and.collision.and.car.side.front.and.steeringwheel
+car.side.rear.and.collision.and.car.side.front.slash
+car.side.rear.and.exclamationmark.and.car.side.front
+car.side.rear.and.exclamationmark.and.car.side.front.off
+car.side.rear.and.wave.3.and.car.side.front
+car.side.rear.crop.trunk.partition
+car.side.rear.crop.trunk.partition.fill
+car.side.rear.open
+car.side.rear.open.crop
+car.side.rear.open.crop.fill
+car.side.rear.open.fill
+car.side.rear.tow.hitch
+car.side.rear.tow.hitch.fill
+car.side.roof.cargo.carrier
+car.side.roof.cargo.carrier.fill
+car.side.roof.cargo.carrier.slash
+car.side.roof.cargo.carrier.slash.fill
+car.top.arrowtriangle.front.left
+car.top.arrowtriangle.front.left.fill
+car.top.arrowtriangle.front.right
+car.top.arrowtriangle.front.right.fill
+car.top.arrowtriangle.rear.left
+car.top.arrowtriangle.rear.left.fill
+car.top.arrowtriangle.rear.right
+car.top.arrowtriangle.rear.right.fill
+car.top.door.front.left.and.front.right.and.rear.left.and.rear.right.open
+car.top.door.front.left.and.front.right.and.rear.left.and.rear.right.open.fill
+car.top.door.front.left.and.front.right.and.rear.left.open
+car.top.door.front.left.and.front.right.and.rear.left.open.fill
+car.top.door.front.left.and.front.right.and.rear.right.open
+car.top.door.front.left.and.front.right.and.rear.right.open.fill
+car.top.door.front.left.and.front.right.open
+car.top.door.front.left.and.front.right.open.fill
+car.top.door.front.left.and.rear.left.and.rear.right.open
+car.top.door.front.left.and.rear.left.and.rear.right.open.fill
+car.top.door.front.left.and.rear.left.open
+car.top.door.front.left.and.rear.left.open.fill
+car.top.door.front.left.and.rear.right.open
+car.top.door.front.left.and.rear.right.open.fill
+car.top.door.front.left.open
+car.top.door.front.left.open.fill
+car.top.door.front.right.and.rear.left.and.rear.right.open
+car.top.door.front.right.and.rear.left.and.rear.right.open.fill
+car.top.door.front.right.and.rear.left.open
+car.top.door.front.right.and.rear.left.open.fill
+car.top.door.front.right.and.rear.right.open
+car.top.door.front.right.and.rear.right.open.fill
+car.top.door.front.right.open
+car.top.door.front.right.open.fill
+car.top.door.rear.left.and.rear.right.open
+car.top.door.rear.left.and.rear.right.open.fill
+car.top.door.rear.left.open
+car.top.door.rear.left.open.fill
+car.top.door.rear.right.open
+car.top.door.rear.right.open.fill
+car.top.door.sliding.left.open
+car.top.door.sliding.left.open.fill
+car.top.door.sliding.right.open
+car.top.door.sliding.right.open.fill
+car.top.front.radiowaves.front.left.and.front.and.front.right
+car.top.front.radiowaves.front.left.and.front.and.front.right.fill
+car.top.frontleft.arrowtriangle
+car.top.frontleft.arrowtriangle.fill
+car.top.frontright.arrowtriangle
+car.top.frontright.arrowtriangle.fill
+car.top.lane.dashed.arrowtriangle.inward
+car.top.lane.dashed.arrowtriangle.inward.fill
+car.top.lane.dashed.badge.steeringwheel
+car.top.lane.dashed.badge.steeringwheel.fill
+car.top.lane.dashed.departure.left
+car.top.lane.dashed.departure.left.fill
+car.top.lane.dashed.departure.left.slash
+car.top.lane.dashed.departure.left.slash.fill
+car.top.lane.dashed.departure.right
+car.top.lane.dashed.departure.right.fill
+car.top.lane.dashed.departure.right.slash
+car.top.lane.dashed.departure.right.slash.fill
+car.top.radiowaves.2.front.left.front.front.right
+car.top.radiowaves.2.front.left.front.front.right.fill
+car.top.radiowaves.2.rear.left.rear.rear.right
+car.top.radiowaves.2.rear.left.rear.rear.right.fill
+car.top.radiowaves.front
+car.top.radiowaves.front.fill
+car.top.radiowaves.rear
+car.top.radiowaves.rear.fill
+car.top.radiowaves.rear.left
+car.top.radiowaves.rear.left.and.rear.right
+car.top.radiowaves.rear.left.and.rear.right.fill
+car.top.radiowaves.rear.left.car.top.front
+car.top.radiowaves.rear.left.car.top.front.fill
+car.top.radiowaves.rear.left.fill
+car.top.radiowaves.rear.right
+car.top.radiowaves.rear.right.badge.exclamationmark
+car.top.radiowaves.rear.right.badge.exclamationmark.fill
+car.top.radiowaves.rear.right.badge.xmark
+car.top.radiowaves.rear.right.badge.xmark.fill
+car.top.radiowaves.rear.right.car.top.front
+car.top.radiowaves.rear.right.car.top.front.fill
+car.top.radiowaves.rear.right.fill
+car.top.rear.radiowaves.rear.left.and.rear.and.rear.right
+car.top.rear.radiowaves.rear.left.and.rear.and.rear.right.fill
+car.top.rearleft.arrowtriangle
+car.top.rearleft.arrowtriangle.fill
+car.top.rearright.arrowtriangle
+car.top.rearright.arrowtriangle.fill
+car.top.video.rear.left
+car.top.video.rear.left.fill
+car.top.video.rear.right
+car.top.video.rear.right.fill
+car.window.left
+car.window.left.badge.exclamationmark
+car.window.left.badge.lock
+car.window.left.badge.xmark
+car.window.left.exclamationmark
+car.window.left.xmark
+car.window.right
+car.window.right.badge.exclamationmark
+car.window.right.badge.lock
+car.window.right.badge.xmark
+car.window.right.exclamationmark
+car.window.right.xmark
+carbon.dioxide.cloud
+carbon.dioxide.cloud.fill
+carbon.monoxide.cloud
+carbon.monoxide.cloud.fill
+carrot
+carrot.fill
+carseat.left
+carseat.left.1
+carseat.left.1.fill
+carseat.left.2
+carseat.left.2.fill
+carseat.left.3
+carseat.left.3.fill
+carseat.left.and.heat.waves
+carseat.left.and.heat.waves.fill
+carseat.left.backrest.up.and.down
+carseat.left.backrest.up.and.down.fill
+carseat.left.fan
+carseat.left.fan.fill
+carseat.left.fill
+carseat.left.forward.and.backward
+carseat.left.forward.and.backward.fill
+carseat.left.massage
+carseat.left.massage.fill
+carseat.left.up.and.down
+carseat.left.up.and.down.fill
+carseat.right
+carseat.right.1
+carseat.right.1.fill
+carseat.right.2
+carseat.right.2.fill
+carseat.right.3
+carseat.right.3.fill
+carseat.right.and.heat.waves
+carseat.right.and.heat.waves.fill
+carseat.right.backrest.up.and.down
+carseat.right.backrest.up.and.down.fill
+carseat.right.fan
+carseat.right.fan.fill
+carseat.right.fill
+carseat.right.forward.and.backward
+carseat.right.forward.and.backward.fill
+carseat.right.massage
+carseat.right.massage.fill
+carseat.right.up.and.down
+carseat.right.up.and.down.fill
+cart
+cart.badge.clock
+cart.badge.clock.fill
+cart.badge.minus
+cart.badge.plus
+cart.badge.questionmark
+cart.circle
+cart.circle.fill
+cart.fill
+cart.fill.badge.minus
+cart.fill.badge.plus
+cart.fill.badge.questionmark
+case
+case.fill
+cat
+cat.circle
+cat.circle.fill
+cat.fill
+cedisign
+cedisign.arrow.circlepath
+cedisign.arrow.trianglehead.counterclockwise.rotate.90
+cedisign.bank.building
+cedisign.bank.building.fill
+cedisign.circle
+cedisign.circle.fill
+cedisign.gauge.chart.lefthalf.righthalf
+cedisign.gauge.chart.leftthird.topthird.rightthird
+cedisign.ring
+cedisign.ring.dashed
+cedisign.square
+cedisign.square.fill
+cellularbars
+cellularbars.circle
+cellularbars.circle.fill
+centsign
+centsign.arrow.circlepath
+centsign.arrow.trianglehead.counterclockwise.rotate.90
+centsign.bank.building
+centsign.bank.building.fill
+centsign.circle
+centsign.circle.fill
+centsign.gauge.chart.lefthalf.righthalf
+centsign.gauge.chart.leftthird.topthird.rightthird
+centsign.ring
+centsign.ring.dashed
+centsign.square
+centsign.square.fill
+chair
+chair.fill
+chair.lounge
+chair.lounge.fill
+chandelier
+chandelier.fill
+character
+character.bn
+character.book.closed
+character.book.closed.bn
+character.book.closed.fill
+character.book.closed.fill.bn
+character.book.closed.fill.gu
+character.book.closed.fill.kn
+character.book.closed.fill.ml
+character.book.closed.fill.mni
+character.book.closed.fill.mr
+character.book.closed.fill.or
+character.book.closed.fill.pa
+character.book.closed.fill.sat
+character.book.closed.fill.si
+character.book.closed.fill.ta
+character.book.closed.fill.te
+character.book.closed.gu
+character.book.closed.kn
+character.book.closed.ml
+character.book.closed.mni
+character.book.closed.mr
+character.book.closed.or
+character.book.closed.pa
+character.book.closed.sat
+character.book.closed.si
+character.book.closed.ta
+character.book.closed.te
+character.bubble
+character.bubble.bn
+character.bubble.fill
+character.bubble.fill.bn
+character.bubble.fill.gu
+character.bubble.fill.kn
+character.bubble.fill.ml
+character.bubble.fill.mni
+character.bubble.fill.mr
+character.bubble.fill.or
+character.bubble.fill.pa
+character.bubble.fill.sat
+character.bubble.fill.si
+character.bubble.fill.ta
+character.bubble.fill.te
+character.bubble.gu
+character.bubble.kn
+character.bubble.ml
+character.bubble.mni
+character.bubble.mr
+character.bubble.or
+character.bubble.pa
+character.bubble.sat
+character.bubble.si
+character.bubble.ta
+character.bubble.te
+character.circle
+character.circle.bn
+character.circle.fill
+character.circle.fill.bn
+character.circle.fill.gu
+character.circle.fill.kn
+character.circle.fill.ml
+character.circle.fill.mni
+character.circle.fill.mr
+character.circle.fill.or
+character.circle.fill.pa
+character.circle.fill.sat
+character.circle.fill.si
+character.circle.fill.ta
+character.circle.fill.te
+character.circle.gu
+character.circle.kn
+character.circle.ml
+character.circle.mni
+character.circle.mr
+character.circle.or
+character.circle.pa
+character.circle.sat
+character.circle.si
+character.circle.ta
+character.circle.te
+character.cursor.ibeam
+character.cursor.ibeam.bn
+character.cursor.ibeam.gu
+character.cursor.ibeam.kn
+character.cursor.ibeam.ml
+character.cursor.ibeam.mni
+character.cursor.ibeam.mr
+character.cursor.ibeam.or
+character.cursor.ibeam.pa
+character.cursor.ibeam.sat
+character.cursor.ibeam.si
+character.cursor.ibeam.ta
+character.cursor.ibeam.te
+character.duployan
+character.gu
+character.kn
+character.magnify
+character.magnify.bn
+character.magnify.gu
+character.magnify.kn
+character.magnify.ml
+character.magnify.mni
+character.magnify.mr
+character.magnify.or
+character.magnify.pa
+character.magnify.sat
+character.magnify.si
+character.magnify.ta
+character.magnify.te
+character.ml
+character.mni
+character.mr
+character.or
+character.pa
+character.phonetic
+character.sat
+character.si
+character.square
+character.square.bn
+character.square.fill
+character.square.fill.bn
+character.square.fill.gu
+character.square.fill.kn
+character.square.fill.ml
+character.square.fill.mni
+character.square.fill.mr
+character.square.fill.or
+character.square.fill.pa
+character.square.fill.sat
+character.square.fill.si
+character.square.fill.ta
+character.square.fill.te
+character.square.gu
+character.square.kn
+character.square.ml
+character.square.mni
+character.square.mr
+character.square.or
+character.square.pa
+character.square.sat
+character.square.si
+character.square.ta
+character.square.te
+character.sutton
+character.ta
+character.te
+character.text.justify
+character.text.justify.bn
+character.text.justify.gu
+character.text.justify.kn
+character.text.justify.ml
+character.text.justify.mni
+character.text.justify.mr
+character.text.justify.or
+character.text.justify.pa
+character.text.justify.sat
+character.text.justify.si
+character.text.justify.ta
+character.text.justify.te
+character.textbox
+character.textbox.badge.sparkles
+character.textbox.badge.sparkles.bn
+character.textbox.badge.sparkles.gu
+character.textbox.badge.sparkles.kn
+character.textbox.badge.sparkles.ml
+character.textbox.badge.sparkles.mni
+character.textbox.badge.sparkles.mr
+character.textbox.badge.sparkles.or
+character.textbox.badge.sparkles.pa
+character.textbox.badge.sparkles.sat
+character.textbox.badge.sparkles.si
+character.textbox.badge.sparkles.ta
+character.textbox.badge.sparkles.te
+character.textbox.bn
+character.textbox.gu
+character.textbox.kn
+character.textbox.ml
+character.textbox.mni
+character.textbox.mr
+character.textbox.or
+character.textbox.pa
+character.textbox.sat
+character.textbox.si
+character.textbox.ta
+character.textbox.te
+characters.lowercase
+characters.lowercase.ru
+characters.uppercase
+characters.uppercase.ru
+chart.bar
+chart.bar.doc.horizontal
+chart.bar.doc.horizontal.fill
+chart.bar.fill
+chart.bar.horizontal.page
+chart.bar.horizontal.page.fill
+chart.bar.xaxis
+chart.bar.xaxis.ascending
+chart.bar.xaxis.ascending.badge.clock
+chart.bar.xaxis.descending
+chart.bar.yaxis
+chart.dots.scatter
+chart.line.downtrend.xyaxis
+chart.line.downtrend.xyaxis.circle
+chart.line.downtrend.xyaxis.circle.fill
+chart.line.flattrend.xyaxis
+chart.line.flattrend.xyaxis.circle
+chart.line.flattrend.xyaxis.circle.fill
+chart.line.text.clipboard
+chart.line.text.clipboard.fill
+chart.line.uptrend.xyaxis
+chart.line.uptrend.xyaxis.circle
+chart.line.uptrend.xyaxis.circle.fill
+chart.pie
+chart.pie.fill
+chart.xyaxis.line
+checkerboard.rectangle
+checkerboard.shield
+checklist
+checklist.checked
+checklist.unchecked
+checkmark
+checkmark.app
+checkmark.app.fill
+checkmark.applewatch
+checkmark.arrow.trianglehead.clockwise
+checkmark.arrow.trianglehead.counterclockwise
+checkmark.bubble
+checkmark.bubble.fill
+checkmark.circle
+checkmark.circle.badge.airplane
+checkmark.circle.badge.airplane.fill
+checkmark.circle.badge.plus
+checkmark.circle.badge.plus.fill
+checkmark.circle.badge.questionmark
+checkmark.circle.badge.questionmark.fill
+checkmark.circle.badge.xmark
+checkmark.circle.badge.xmark.fill
+checkmark.circle.dotted
+checkmark.circle.fill
+checkmark.circle.trianglebadge.exclamationmark
+checkmark.circle.trianglebadge.exclamationmark.fill
+checkmark.diamond
+checkmark.diamond.fill
+checkmark.gobackward
+checkmark.icloud
+checkmark.icloud.fill
+checkmark.message
+checkmark.message.fill
+checkmark.rectangle
+checkmark.rectangle.fill
+checkmark.rectangle.portrait
+checkmark.rectangle.portrait.fill
+checkmark.rectangle.stack
+checkmark.rectangle.stack.fill
+checkmark.seal
+checkmark.seal.fill
+checkmark.seal.text.page
+checkmark.seal.text.page.fill
+checkmark.shield
+checkmark.shield.fill
+checkmark.square
+checkmark.square.fill
+chevron.backward
+chevron.backward.2
+chevron.backward.chevron.backward.dotted
+chevron.backward.circle
+chevron.backward.circle.fill
+chevron.backward.square
+chevron.backward.square.fill
+chevron.backward.to.line
+chevron.compact.backward
+chevron.compact.down
+chevron.compact.forward
+chevron.compact.left
+chevron.compact.left.chevron.compact.right
+chevron.compact.right
+chevron.compact.up
+chevron.compact.up.chevron.compact.down
+chevron.compact.up.chevron.compact.right.chevron.compact.down.chevron.compact.left
+chevron.down
+chevron.down.2
+chevron.down.circle
+chevron.down.circle.fill
+chevron.down.dotted.2
+chevron.down.forward.2
+chevron.down.forward.dotted.2
+chevron.down.right.2
+chevron.down.right.dotted.2
+chevron.down.square
+chevron.down.square.fill
+chevron.forward
+chevron.forward.2
+chevron.forward.circle
+chevron.forward.circle.fill
+chevron.forward.dotted.chevron.forward
+chevron.forward.square
+chevron.forward.square.fill
+chevron.forward.to.line
+chevron.left
+chevron.left.2
+chevron.left.chevron.left.dotted
+chevron.left.chevron.right
+chevron.left.circle
+chevron.left.circle.fill
+chevron.left.forwardslash.chevron.right
+chevron.left.slash.chevron.right
+chevron.left.square
+chevron.left.square.fill
+chevron.left.to.line
+chevron.right
+chevron.right.2
+chevron.right.circle
+chevron.right.circle.fill
+chevron.right.dotted.chevron.right
+chevron.right.square
+chevron.right.square.fill
+chevron.right.to.line
+chevron.up
+chevron.up.2
+chevron.up.chevron.down
+chevron.up.chevron.down.square
+chevron.up.chevron.down.square.fill
+chevron.up.chevron.right.chevron.down.chevron.left
+chevron.up.circle
+chevron.up.circle.fill
+chevron.up.dotted.2
+chevron.up.forward.2
+chevron.up.forward.dotted.2
+chevron.up.right.2
+chevron.up.right.dotted.2
+chevron.up.square
+chevron.up.square.fill
+chineseyuanrenminbisign
+chineseyuanrenminbisign.arrow.circlepath
+chineseyuanrenminbisign.arrow.trianglehead.counterclockwise.rotate.90
+chineseyuanrenminbisign.bank.building
+chineseyuanrenminbisign.bank.building.fill
+chineseyuanrenminbisign.circle
+chineseyuanrenminbisign.circle.fill
+chineseyuanrenminbisign.gauge.chart.lefthalf.righthalf
+chineseyuanrenminbisign.gauge.chart.leftthird.topthird.rightthird
+chineseyuanrenminbisign.ring
+chineseyuanrenminbisign.ring.dashed
+chineseyuanrenminbisign.square
+chineseyuanrenminbisign.square.fill
+circle
+circle.and.line.horizontal
+circle.and.line.horizontal.fill
+circle.badge.checkmark
+circle.badge.checkmark.fill
+circle.badge.exclamationmark
+circle.badge.exclamationmark.fill
+circle.badge.minus
+circle.badge.minus.fill
+circle.badge.plus
+circle.badge.plus.fill
+circle.badge.questionmark
+circle.badge.questionmark.fill
+circle.badge.xmark
+circle.badge.xmark.fill
+circle.bottomhalf.fill
+circle.bottomhalf.filled
+circle.bottomhalf.filled.inverse
+circle.bottomrighthalf.checkered
+circle.bottomrighthalf.pattern.checkered
+circle.bottomthird.split
+circle.circle
+circle.circle.fill
+circle.dashed
+circle.dashed.inset.fill
+circle.dashed.inset.filled
+circle.dashed.rectangle
+circle.dotted
+circle.dotted.and.circle
+circle.dotted.circle
+circle.dotted.circle.fill
+circle.fill
+circle.fill.square.fill
+circle.filled.ipad
+circle.filled.ipad.fill
+circle.filled.ipad.landscape
+circle.filled.ipad.landscape.fill
+circle.filled.iphone
+circle.filled.iphone.fill
+circle.filled.pattern.diagonalline.rectangle
+circle.grid.2x1
+circle.grid.2x1.fill
+circle.grid.2x1.left.filled
+circle.grid.2x1.right.filled
+circle.grid.2x2
+circle.grid.2x2.fill
+circle.grid.2x2.topleft.checkmark.filled
+circle.grid.3x3
+circle.grid.3x3.circle
+circle.grid.3x3.circle.fill
+circle.grid.3x3.fill
+circle.grid.cross
+circle.grid.cross.down.fill
+circle.grid.cross.down.filled
+circle.grid.cross.fill
+circle.grid.cross.left.fill
+circle.grid.cross.left.filled
+circle.grid.cross.right.fill
+circle.grid.cross.right.filled
+circle.grid.cross.up.fill
+circle.grid.cross.up.filled
+circle.grid.hex
+circle.grid.hex.fill
+circle.hexagongrid
+circle.hexagongrid.circle
+circle.hexagongrid.circle.fill
+circle.hexagongrid.fill
+circle.hexagonpath
+circle.hexagonpath.fill
+circle.inset.filled
+circle.lefthalf.fill
+circle.lefthalf.filled
+circle.lefthalf.filled.inverse
+circle.lefthalf.filled.righthalf.striped.horizontal
+circle.lefthalf.filled.righthalf.striped.horizontal.inverse
+circle.lefthalf.striped.horizontal
+circle.lefthalf.striped.horizontal.inverse
+circle.on.square
+circle.on.square.intersection.dotted
+circle.on.square.merge
+circle.rectangle.dashed
+circle.rectangle.filled.pattern.diagonalline
+circle.righthalf.fill
+circle.righthalf.filled
+circle.righthalf.filled.inverse
+circle.slash
+circle.slash.fill
+circle.square
+circle.square.fill
+circle.tophalf.fill
+circle.tophalf.filled
+circle.tophalf.filled.inverse
+circlebadge
+circlebadge.2
+circlebadge.2.fill
+circlebadge.fill
+circles.hexagongrid
+circles.hexagongrid.fill
+circles.hexagonpath
+circles.hexagonpath.fill
+clear
+clear.fill
+clipboard
+clipboard.fill
+clock
+clock.arrow.2.circlepath
+clock.arrow.circlepath
+clock.arrow.trianglehead.2.counterclockwise.rotate.90
+clock.arrow.trianglehead.clockwise.rotate.90.path.dotted
+clock.arrow.trianglehead.counterclockwise.rotate.90
+clock.badge
+clock.badge.airplane
+clock.badge.airplane.fill
+clock.badge.checkmark
+clock.badge.checkmark.fill
+clock.badge.exclamationmark
+clock.badge.exclamationmark.fill
+clock.badge.fill
+clock.badge.questionmark
+clock.badge.questionmark.fill
+clock.badge.xmark
+clock.badge.xmark.fill
+clock.circle
+clock.circle.fill
+clock.fill
+cloud
+cloud.bolt
+cloud.bolt.circle
+cloud.bolt.circle.fill
+cloud.bolt.fill
+cloud.bolt.rain
+cloud.bolt.rain.circle
+cloud.bolt.rain.circle.fill
+cloud.bolt.rain.fill
+cloud.circle
+cloud.circle.fill
+cloud.drizzle
+cloud.drizzle.circle
+cloud.drizzle.circle.fill
+cloud.drizzle.fill
+cloud.fill
+cloud.fog
+cloud.fog.circle
+cloud.fog.circle.fill
+cloud.fog.fill
+cloud.hail
+cloud.hail.circle
+cloud.hail.circle.fill
+cloud.hail.fill
+cloud.heavyrain
+cloud.heavyrain.circle
+cloud.heavyrain.circle.fill
+cloud.heavyrain.fill
+cloud.moon
+cloud.moon.bolt
+cloud.moon.bolt.circle
+cloud.moon.bolt.circle.fill
+cloud.moon.bolt.fill
+cloud.moon.circle
+cloud.moon.circle.fill
+cloud.moon.fill
+cloud.moon.rain
+cloud.moon.rain.circle
+cloud.moon.rain.circle.fill
+cloud.moon.rain.fill
+cloud.rain
+cloud.rain.circle
+cloud.rain.circle.fill
+cloud.rain.fill
+cloud.rainbow.crop
+cloud.rainbow.crop.fill
+cloud.rainbow.half
+cloud.rainbow.half.fill
+cloud.sleet
+cloud.sleet.circle
+cloud.sleet.circle.fill
+cloud.sleet.fill
+cloud.snow
+cloud.snow.circle
+cloud.snow.circle.fill
+cloud.snow.fill
+cloud.sun
+cloud.sun.bolt
+cloud.sun.bolt.circle
+cloud.sun.bolt.circle.fill
+cloud.sun.bolt.fill
+cloud.sun.circle
+cloud.sun.circle.fill
+cloud.sun.fill
+cloud.sun.rain
+cloud.sun.rain.circle
+cloud.sun.rain.circle.fill
+cloud.sun.rain.fill
+coat
+coat.circle
+coat.circle.fill
+coat.fill
+coloncurrencysign
+coloncurrencysign.arrow.circlepath
+coloncurrencysign.arrow.trianglehead.counterclockwise.rotate.90
+coloncurrencysign.bank.building
+coloncurrencysign.bank.building.fill
+coloncurrencysign.circle
+coloncurrencysign.circle.fill
+coloncurrencysign.gauge.chart.lefthalf.righthalf
+coloncurrencysign.gauge.chart.leftthird.topthird.rightthird
+coloncurrencysign.ring
+coloncurrencysign.ring.dashed
+coloncurrencysign.square
+coloncurrencysign.square.fill
+comb
+comb.fill
+command
+command.circle
+command.circle.fill
+command.square
+command.square.fill
+compass.drawing
+computermouse
+computermouse.fill
+cone
+cone.fill
+contact.sensor
+contact.sensor.fill
+contextualmenu.and.cursorarrow
+contextualmenu.and.pointer.arrow
+control
+convertible.side
+convertible.side.air.circulate
+convertible.side.air.circulate.fill
+convertible.side.air.fresh
+convertible.side.air.fresh.fill
+convertible.side.and.exclamationmark
+convertible.side.and.exclamationmark.fill
+convertible.side.arrow.left.and.right
+convertible.side.arrow.left.and.right.fill
+convertible.side.arrow.trianglehead.backward
+convertible.side.arrow.trianglehead.backward.fill
+convertible.side.arrow.trianglehead.forward
+convertible.side.arrow.trianglehead.forward.and.backward
+convertible.side.arrow.trianglehead.forward.and.backward.fill
+convertible.side.arrow.trianglehead.forward.fill
+convertible.side.arrowtriangle.down
+convertible.side.arrowtriangle.down.fill
+convertible.side.arrowtriangle.up
+convertible.side.arrowtriangle.up.arrowtriangle.down
+convertible.side.arrowtriangle.up.arrowtriangle.down.fill
+convertible.side.arrowtriangle.up.fill
+convertible.side.fill
+convertible.side.front.open
+convertible.side.front.open.crop
+convertible.side.front.open.crop.fill
+convertible.side.front.open.fill
+convertible.side.hill.descent.control
+convertible.side.hill.descent.control.fill
+convertible.side.hill.down
+convertible.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle
+convertible.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle.fill
+convertible.side.hill.down.fill
+convertible.side.hill.up
+convertible.side.hill.up.fill
+convertible.side.lock
+convertible.side.lock.fill
+convertible.side.lock.open
+convertible.side.lock.open.fill
+cooktop
+cooktop.fill
+cpu
+cpu.fill
+creditcard
+creditcard.and.123
+creditcard.and.numbers
+creditcard.arrow.trianglehead.2.clockwise.rotate.90
+creditcard.circle
+creditcard.circle.fill
+creditcard.fill
+creditcard.rewards
+creditcard.rewards.fill
+creditcard.trianglebadge.exclamationmark
+creditcard.trianglebadge.exclamationmark.fill
+creditcard.viewfinder
+cricket.ball
+cricket.ball.circle
+cricket.ball.circle.fill
+cricket.ball.fill
+crop
+crop.rotate
+cross
+cross.case
+cross.case.circle
+cross.case.circle.fill
+cross.case.fill
+cross.circle
+cross.circle.fill
+cross.fill
+cross.vial
+cross.vial.fill
+crown
+crown.fill
+cruzeirosign
+cruzeirosign.arrow.circlepath
+cruzeirosign.arrow.trianglehead.counterclockwise.rotate.90
+cruzeirosign.bank.building
+cruzeirosign.bank.building.fill
+cruzeirosign.circle
+cruzeirosign.circle.fill
+cruzeirosign.gauge.chart.lefthalf.righthalf
+cruzeirosign.gauge.chart.leftthird.topthird.rightthird
+cruzeirosign.ring
+cruzeirosign.ring.dashed
+cruzeirosign.square
+cruzeirosign.square.fill
+cube
+cube.box
+cube.box.fill
+cube.circle
+cube.circle.fill
+cube.fill
+cube.transparent
+cube.transparent.fill
+cup.and.heat.waves
+cup.and.heat.waves.fill
+cup.and.saucer
+cup.and.saucer.fill
+curlybraces
+curlybraces.square
+curlybraces.square.fill
+cursor.rays
+cursorarrow
+cursorarrow.and.square.on.square.dashed
+cursorarrow.click
+cursorarrow.click.2
+cursorarrow.click.badge.clock
+cursorarrow.motionlines
+cursorarrow.motionlines.click
+cursorarrow.rays
+cursorarrow.resize.north.east.south.east
+cursorarrow.slash
+cursorarrow.slash.square
+cursorarrow.slash.square.fill
+cursorarrow.square
+cursorarrow.square.fill
+curtains.closed
+curtains.open
+cylinder
+cylinder.fill
+cylinder.split.1x2
+cylinder.split.1x2.fill
+d.circle
+d.circle.fill
+d.square
+d.square.fill
+danishkronesign
+danishkronesign.arrow.circlepath
+danishkronesign.arrow.trianglehead.counterclockwise.rotate.90
+danishkronesign.bank.building
+danishkronesign.bank.building.fill
+danishkronesign.circle
+danishkronesign.circle.fill
+danishkronesign.gauge.chart.lefthalf.righthalf
+danishkronesign.gauge.chart.leftthird.topthird.rightthird
+danishkronesign.ring
+danishkronesign.ring.dashed
+danishkronesign.square
+danishkronesign.square.fill
+decrease.indent
+decrease.quotelevel
+degreesign.celsius
+degreesign.fahrenheit
+dehumidifier
+dehumidifier.fill
+delete.backward
+delete.backward.fill
+delete.forward
+delete.forward.fill
+delete.left
+delete.left.fill
+delete.right
+delete.right.fill
+deskclock
+deskclock.fill
+desktopcomputer
+desktopcomputer.and.arrow.down
+desktopcomputer.and.macbook
+desktopcomputer.badge.checkmark
+desktopcomputer.badge.shield.checkmark
+desktopcomputer.trianglebadge.exclamationmark
+deskview
+deskview.fill
+dial
+dial.fill
+dial.high
+dial.high.fill
+dial.low
+dial.low.fill
+dial.max
+dial.max.fill
+dial.medium
+dial.medium.fill
+dial.min
+dial.min.fill
+diamond
+diamond.bottomhalf.filled
+diamond.circle
+diamond.circle.fill
+diamond.fill
+diamond.inset.filled
+diamond.lefthalf.filled
+diamond.righthalf.filled
+diamond.tophalf.filled
+dice
+dice.fill
+die.face.1
+die.face.1.fill
+die.face.2
+die.face.2.fill
+die.face.3
+die.face.3.fill
+die.face.4
+die.face.4.fill
+die.face.5
+die.face.5.fill
+die.face.6
+die.face.6.fill
+digitalcrown
+digitalcrown.arrow.clockwise
+digitalcrown.arrow.clockwise.fill
+digitalcrown.arrow.counterclockwise
+digitalcrown.arrow.counterclockwise.fill
+digitalcrown.fill
+digitalcrown.horizontal
+digitalcrown.horizontal.arrow.clockwise
+digitalcrown.horizontal.arrow.clockwise.fill
+digitalcrown.horizontal.arrow.counterclockwise
+digitalcrown.horizontal.arrow.counterclockwise.fill
+digitalcrown.horizontal.fill
+digitalcrown.horizontal.press
+digitalcrown.horizontal.press.fill
+digitalcrown.press
+digitalcrown.press.fill
+directcurrent
+dishwasher
+dishwasher.circle
+dishwasher.circle.fill
+dishwasher.fill
+display
+display.2
+display.and.arrow.down
+display.and.screwdriver
+display.trianglebadge.exclamationmark
+distribute.horizontal
+distribute.horizontal.center
+distribute.horizontal.center.fill
+distribute.horizontal.fill
+distribute.horizontal.left
+distribute.horizontal.left.fill
+distribute.horizontal.right
+distribute.horizontal.right.fill
+distribute.vertical
+distribute.vertical.bottom
+distribute.vertical.bottom.fill
+distribute.vertical.center
+distribute.vertical.center.fill
+distribute.vertical.fill
+distribute.vertical.top
+distribute.vertical.top.fill
+divide
+divide.circle
+divide.circle.fill
+divide.square
+divide.square.fill
+doc
+doc.append
+doc.append.fill
+doc.badge.arrow.up
+doc.badge.arrow.up.fill
+doc.badge.clock
+doc.badge.clock.fill
+doc.badge.ellipsis
+doc.badge.gearshape
+doc.badge.gearshape.fill
+doc.badge.plus
+doc.circle
+doc.circle.fill
+doc.fill
+doc.fill.badge.ellipsis
+doc.fill.badge.plus
+doc.on.clipboard
+doc.on.clipboard.fill
+doc.on.doc
+doc.on.doc.fill
+doc.plaintext
+doc.plaintext.fill
+doc.questionmark
+doc.questionmark.fill
+doc.richtext
+doc.richtext.fill
+doc.text
+doc.text.below.ecg
+doc.text.below.ecg.fill
+doc.text.fill
+doc.text.fill.viewfinder
+doc.text.image
+doc.text.image.fill
+doc.text.magnifyingglass
+doc.text.viewfinder
+doc.viewfinder
+doc.viewfinder.fill
+doc.zipper
+dock.arrow.down.rectangle
+dock.arrow.up.rectangle
+dock.rectangle
+document
+document.badge.arrow.up
+document.badge.arrow.up.fill
+document.badge.clock
+document.badge.clock.fill
+document.badge.ellipsis
+document.badge.ellipsis.fill
+document.badge.gearshape
+document.badge.gearshape.fill
+document.badge.plus
+document.badge.plus.fill
+document.circle
+document.circle.fill
+document.fill
+document.on.clipboard
+document.on.clipboard.fill
+document.on.document
+document.on.document.fill
+document.on.trash
+document.on.trash.fill
+document.viewfinder
+document.viewfinder.fill
+dog
+dog.circle
+dog.circle.fill
+dog.fill
+dollarsign
+dollarsign.arrow.circlepath
+dollarsign.arrow.trianglehead.counterclockwise.rotate.90
+dollarsign.bank.building
+dollarsign.bank.building.fill
+dollarsign.circle
+dollarsign.circle.fill
+dollarsign.gauge.chart.lefthalf.righthalf
+dollarsign.gauge.chart.leftthird.topthird.rightthird
+dollarsign.ring
+dollarsign.ring.dashed
+dollarsign.square
+dollarsign.square.fill
+dongsign
+dongsign.arrow.circlepath
+dongsign.arrow.trianglehead.counterclockwise.rotate.90
+dongsign.bank.building
+dongsign.bank.building.fill
+dongsign.circle
+dongsign.circle.fill
+dongsign.gauge.chart.lefthalf.righthalf
+dongsign.gauge.chart.leftthird.topthird.rightthird
+dongsign.ring
+dongsign.ring.dashed
+dongsign.square
+dongsign.square.fill
+door.french.closed
+door.french.open
+door.garage.closed
+door.garage.closed.trianglebadge.exclamationmark
+door.garage.double.bay.closed
+door.garage.double.bay.closed.trianglebadge.exclamationmark
+door.garage.double.bay.open
+door.garage.double.bay.open.trianglebadge.exclamationmark
+door.garage.open
+door.garage.open.trianglebadge.exclamationmark
+door.left.hand.closed
+door.left.hand.open
+door.right.hand.closed
+door.right.hand.open
+door.sliding.left.hand.closed
+door.sliding.left.hand.open
+door.sliding.right.hand.closed
+door.sliding.right.hand.open
+dot.arrowtriangles.up.right.down.left.circle
+dot.car.top.radiowaves.2.rear.left.rear.rear.right
+dot.car.top.radiowaves.2.rear.left.rear.rear.right.fill
+dot.circle.and.cursorarrow
+dot.circle.and.hand.point.up.left.fill
+dot.circle.and.pointer.arrow
+dot.circle.viewfinder
+dot.crosshair
+dot.radiowaves.forward
+dot.radiowaves.left.and.right
+dot.radiowaves.right
+dot.radiowaves.up.forward
+dot.scope
+dot.scope.display
+dot.scope.laptopcomputer
+dot.square
+dot.square.fill
+dot.squareshape
+dot.squareshape.fill
+dot.squareshape.split.2x2
+dot.viewfinder
+dots.and.line.vertical.and.cursorarrow.rectangle
+dots.and.line.vertical.and.pointer.arrow.rectangle
+dpad
+dpad.down.fill
+dpad.down.filled
+dpad.fill
+dpad.left.fill
+dpad.left.filled
+dpad.right.fill
+dpad.right.filled
+dpad.up.fill
+dpad.up.filled
+drone
+drone.fill
+drop
+drop.circle
+drop.circle.fill
+drop.degreesign
+drop.degreesign.fill
+drop.degreesign.slash
+drop.degreesign.slash.fill
+drop.fill
+drop.halffull
+drop.keypad.rectangle
+drop.keypad.rectangle.fill
+drop.transmission
+drop.triangle
+drop.triangle.fill
+dryer
+dryer.circle
+dryer.circle.fill
+dryer.fill
+duffle.bag
+duffle.bag.fill
+dumbbell
+dumbbell.fill
+e.circle
+e.circle.fill
+e.square
+e.square.fill
+ear
+ear.and.waveform
+ear.badge.checkmark
+ear.badge.waveform
+ear.fill
+ear.trianglebadge.exclamationmark
+earbud.left
+earbud.right
+earbuds
+earbuds.bone.conduction
+earbuds.bone.conduction.left
+earbuds.bone.conduction.right
+earbuds.case
+earbuds.case.fill
+earbuds.in.ear
+earbuds.in.ear.left
+earbuds.in.ear.right
+earbuds.stemless
+earbuds.stemless.left
+earbuds.stemless.right
+earpods
+ecg.text.page
+ecg.text.page.fill
+eject
+eject.circle
+eject.circle.fill
+eject.fill
+electronic.toll.collection
+electronic.toll.collection.rectangle
+electronic.toll.collection.rectangle.fill
+electronic.toll.collection.rectangle.slash
+electronic.toll.collection.rectangle.slash.fill
+electronic.toll.collection.rectangle.trianglebadge.exclamationmark
+electronic.toll.collection.rectangle.trianglebadge.exclamationmark.fill
+ellipses.bubble
+ellipses.bubble.fill
+ellipsis
+ellipsis.bubble
+ellipsis.bubble.fill
+ellipsis.calendar
+ellipsis.circle
+ellipsis.circle.badge
+ellipsis.circle.badge.fill
+ellipsis.circle.fill
+ellipsis.curlybraces
+ellipsis.message
+ellipsis.message.fill
+ellipsis.rectangle
+ellipsis.rectangle.fill
+ellipsis.vertical.bubble
+ellipsis.vertical.bubble.fill
+ellipsis.viewfinder
+engine.combustion
+engine.combustion.badge.exclamationmark
+engine.combustion.badge.exclamationmark.fill
+engine.combustion.fill
+engine.emission.and.drop.2.water.wave.below
+engine.emission.and.exclamationmark
+engine.emission.and.filter
+entry.lever.keypad
+entry.lever.keypad.fill
+entry.lever.keypad.trianglebadge.exclamationmark
+entry.lever.keypad.trianglebadge.exclamationmark.fill
+envelope
+envelope.and.arrow.3.down
+envelope.and.arrow.3.down.fill
+envelope.and.arrow.trianglehead.branch
+envelope.and.arrow.trianglehead.branch.fill
+envelope.and.hand.raised
+envelope.and.hand.raised.fill
+envelope.arrow.triangle.branch
+envelope.arrow.triangle.branch.fill
+envelope.badge
+envelope.badge.fill
+envelope.badge.minus
+envelope.badge.minus.fill
+envelope.badge.person.crop
+envelope.badge.person.crop.fill
+envelope.badge.plus
+envelope.badge.plus.fill
+envelope.badge.shield.half.filled
+envelope.badge.shield.half.filled.fill
+envelope.badge.shield.leadinghalf.fill
+envelope.circle
+envelope.circle.fill
+envelope.fill
+envelope.fill.badge.shield.trailinghalf.fill
+envelope.front
+envelope.front.fill
+envelope.open
+envelope.open.badge.clock
+envelope.open.badge.clock.fill
+envelope.open.fill
+envelope.stack
+envelope.stack.fill
+environments
+environments.circle
+environments.circle.fill
+environments.fill
+environments.slash
+environments.slash.circle
+environments.slash.circle.fill
+environments.slash.fill
+equal
+equal.circle
+equal.circle.fill
+equal.square
+equal.square.fill
+eraser
+eraser.badge.xmark
+eraser.badge.xmark.fill
+eraser.fill
+eraser.line.dashed
+eraser.line.dashed.fill
+eraser.slash
+eraser.slash.fill
+eraser.trianglebadge.exclamationmark
+eraser.trianglebadge.exclamationmark.fill
+escape
+esim
+esim.fill
+eurosign
+eurosign.arrow.circlepath
+eurosign.arrow.trianglehead.counterclockwise.rotate.90
+eurosign.bank.building
+eurosign.bank.building.fill
+eurosign.circle
+eurosign.circle.fill
+eurosign.gauge.chart.lefthalf.righthalf
+eurosign.gauge.chart.leftthird.topthird.rightthird
+eurosign.ring
+eurosign.ring.dashed
+eurosign.square
+eurosign.square.fill
+eurozonesign
+eurozonesign.arrow.circlepath
+eurozonesign.arrow.trianglehead.counterclockwise.rotate.90
+eurozonesign.bank.building
+eurozonesign.bank.building.fill
+eurozonesign.circle
+eurozonesign.circle.fill
+eurozonesign.gauge.chart.lefthalf.righthalf
+eurozonesign.gauge.chart.leftthird.topthird.rightthird
+eurozonesign.ring
+eurozonesign.ring.dashed
+eurozonesign.square
+eurozonesign.square.fill
+ev.charger
+ev.charger.arrowtriangle.left
+ev.charger.arrowtriangle.left.fill
+ev.charger.arrowtriangle.right
+ev.charger.arrowtriangle.right.fill
+ev.charger.exclamationmark
+ev.charger.exclamationmark.fill
+ev.charger.fill
+ev.charger.slash
+ev.charger.slash.fill
+ev.plug.ac.gb.t
+ev.plug.ac.gb.t.fill
+ev.plug.ac.type.1
+ev.plug.ac.type.1.fill
+ev.plug.ac.type.2
+ev.plug.ac.type.2.fill
+ev.plug.dc.ccs1
+ev.plug.dc.ccs1.fill
+ev.plug.dc.ccs2
+ev.plug.dc.ccs2.fill
+ev.plug.dc.chademo
+ev.plug.dc.chademo.fill
+ev.plug.dc.gb.t
+ev.plug.dc.gb.t.fill
+ev.plug.dc.nacs
+ev.plug.dc.nacs.fill
+exclamationmark
+exclamationmark.2
+exclamationmark.3
+exclamationmark.applewatch
+exclamationmark.arrow.circlepath
+exclamationmark.arrow.triangle.2.circlepath
+exclamationmark.arrow.trianglehead.2.clockwise.rotate.90
+exclamationmark.arrow.trianglehead.counterclockwise.rotate.90
+exclamationmark.brakesignal
+exclamationmark.bubble
+exclamationmark.bubble.circle
+exclamationmark.bubble.circle.fill
+exclamationmark.bubble.fill
+exclamationmark.circle
+exclamationmark.circle.fill
+exclamationmark.icloud
+exclamationmark.icloud.fill
+exclamationmark.lock
+exclamationmark.lock.fill
+exclamationmark.magnifyingglass
+exclamationmark.message
+exclamationmark.message.fill
+exclamationmark.octagon
+exclamationmark.octagon.fill
+exclamationmark.questionmark
+exclamationmark.shield
+exclamationmark.shield.fill
+exclamationmark.square
+exclamationmark.square.fill
+exclamationmark.tirepressure
+exclamationmark.transmission
+exclamationmark.triangle
+exclamationmark.triangle.fill
+exclamationmark.triangle.text.page
+exclamationmark.triangle.text.page.fill
+exclamationmark.warninglight
+exclamationmark.warninglight.fill
+externaldrive
+externaldrive.badge.checkmark
+externaldrive.badge.exclamationmark
+externaldrive.badge.icloud
+externaldrive.badge.minus
+externaldrive.badge.person.crop
+externaldrive.badge.plus
+externaldrive.badge.questionmark
+externaldrive.badge.timemachine
+externaldrive.badge.wifi
+externaldrive.badge.xmark
+externaldrive.connected.to.line.below
+externaldrive.connected.to.line.below.fill
+externaldrive.fill
+externaldrive.fill.badge.checkmark
+externaldrive.fill.badge.exclamationmark
+externaldrive.fill.badge.icloud
+externaldrive.fill.badge.minus
+externaldrive.fill.badge.person.crop
+externaldrive.fill.badge.plus
+externaldrive.fill.badge.questionmark
+externaldrive.fill.badge.timemachine
+externaldrive.fill.badge.wifi
+externaldrive.fill.badge.xmark
+externaldrive.fill.trianglebadge.exclamationmark
+externaldrive.trianglebadge.exclamationmark
+eye
+eye.circle
+eye.circle.fill
+eye.fill
+eye.half.closed
+eye.half.closed.fill
+eye.slash
+eye.slash.circle
+eye.slash.circle.fill
+eye.slash.fill
+eye.square
+eye.square.fill
+eye.trianglebadge.exclamationmark
+eye.trianglebadge.exclamationmark.fill
+eyebrow
+eyedropper
+eyedropper.full
+eyedropper.halffull
+eyeglasses
+eyeglasses.slash
+eyes
+eyes.inverse
+f.circle
+f.circle.fill
+f.cursive
+f.cursive.circle
+f.cursive.circle.fill
+f.cursive.slash
+f.square
+f.square.fill
+face.dashed
+face.dashed.fill
+face.smiling
+face.smiling.fill
+face.smiling.inverse
+faceid
+facemask
+facemask.fill
+fan
+fan.and.light.ceiling
+fan.and.light.ceiling.fill
+fan.badge.arrow.up.and.down.and.arrow.left.and.right
+fan.badge.arrow.up.and.down.and.arrow.left.and.right.fill
+fan.badge.automatic
+fan.badge.automatic.fill
+fan.ceiling
+fan.ceiling.fill
+fan.circle
+fan.circle.fill
+fan.desk
+fan.desk.fill
+fan.fill
+fan.floor
+fan.floor.fill
+fan.gauge.open
+fan.oscillation
+fan.oscillation.fill
+fan.slash
+fan.slash.fill
+fanblades
+fanblades.fill
+fanblades.slash
+fanblades.slash.fill
+faxmachine
+faxmachine.fill
+ferry
+ferry.fill
+fiberchannel
+fibrechannel
+field.of.view.ultrawide
+field.of.view.ultrawide.fill
+field.of.view.wide
+field.of.view.wide.fill
+figure
+figure.2
+figure.2.and.child.holdinghands
+figure.2.arms.open
+figure.2.circle
+figure.2.circle.fill
+figure.2.left.holdinghands
+figure.2.right.holdinghands
+figure.american.football
+figure.american.football.circle
+figure.american.football.circle.fill
+figure.and.child.holdinghands
+figure.archery
+figure.archery.circle
+figure.archery.circle.fill
+figure.arms.open
+figure.australian.football
+figure.australian.football.circle
+figure.australian.football.circle.fill
+figure.badminton
+figure.badminton.circle
+figure.badminton.circle.fill
+figure.barre
+figure.barre.circle
+figure.barre.circle.fill
+figure.baseball
+figure.baseball.circle
+figure.baseball.circle.fill
+figure.basketball
+figure.basketball.circle
+figure.basketball.circle.fill
+figure.bowling
+figure.bowling.circle
+figure.bowling.circle.fill
+figure.boxing
+figure.boxing.circle
+figure.boxing.circle.fill
+figure.child
+figure.child.and.lock
+figure.child.and.lock.fill
+figure.child.and.lock.open
+figure.child.and.lock.open.fill
+figure.child.circle
+figure.child.circle.fill
+figure.climbing
+figure.climbing.circle
+figure.climbing.circle.fill
+figure.cooldown
+figure.cooldown.circle
+figure.cooldown.circle.fill
+figure.core.training
+figure.core.training.circle
+figure.core.training.circle.fill
+figure.cricket
+figure.cricket.circle
+figure.cricket.circle.fill
+figure.cross.training
+figure.cross.training.circle
+figure.cross.training.circle.fill
+figure.curling
+figure.curling.circle
+figure.curling.circle.fill
+figure.dance
+figure.dance.circle
+figure.dance.circle.fill
+figure.disc.sports
+figure.disc.sports.circle
+figure.disc.sports.circle.fill
+figure.dress.line.vertical.figure
+figure.elliptical
+figure.elliptical.circle
+figure.elliptical.circle.fill
+figure.equestrian.sports
+figure.equestrian.sports.circle
+figure.equestrian.sports.circle.fill
+figure.fall
+figure.fall.circle
+figure.fall.circle.fill
+figure.fencing
+figure.fencing.circle
+figure.fencing.circle.fill
+figure.field.hockey
+figure.field.hockey.circle
+figure.field.hockey.circle.fill
+figure.fishing
+figure.fishing.circle
+figure.fishing.circle.fill
+figure.flexibility
+figure.flexibility.circle
+figure.flexibility.circle.fill
+figure.golf
+figure.golf.circle
+figure.golf.circle.fill
+figure.gymnastics
+figure.gymnastics.circle
+figure.gymnastics.circle.fill
+figure.hand.cycling
+figure.hand.cycling.circle
+figure.hand.cycling.circle.fill
+figure.handball
+figure.handball.circle
+figure.handball.circle.fill
+figure.highintensity.intervaltraining
+figure.highintensity.intervaltraining.circle
+figure.highintensity.intervaltraining.circle.fill
+figure.hiking
+figure.hiking.circle
+figure.hiking.circle.fill
+figure.hockey
+figure.hockey.circle
+figure.hockey.circle.fill
+figure.hunting
+figure.hunting.circle
+figure.hunting.circle.fill
+figure.ice.hockey
+figure.ice.hockey.circle
+figure.ice.hockey.circle.fill
+figure.ice.skating
+figure.ice.skating.circle
+figure.ice.skating.circle.fill
+figure.indoor.cycle
+figure.indoor.cycle.circle
+figure.indoor.cycle.circle.fill
+figure.indoor.rowing
+figure.indoor.rowing.circle
+figure.indoor.rowing.circle.fill
+figure.indoor.soccer
+figure.indoor.soccer.circle
+figure.indoor.soccer.circle.fill
+figure.jumprope
+figure.jumprope.circle
+figure.jumprope.circle.fill
+figure.kickboxing
+figure.kickboxing.circle
+figure.kickboxing.circle.fill
+figure.lacrosse
+figure.lacrosse.circle
+figure.lacrosse.circle.fill
+figure.martial.arts
+figure.martial.arts.circle
+figure.martial.arts.circle.fill
+figure.mind.and.body
+figure.mind.and.body.circle
+figure.mind.and.body.circle.fill
+figure.mixed.cardio
+figure.mixed.cardio.circle
+figure.mixed.cardio.circle.fill
+figure.open.water.swim
+figure.open.water.swim.circle
+figure.open.water.swim.circle.fill
+figure.outdoor.cycle
+figure.outdoor.cycle.circle
+figure.outdoor.cycle.circle.fill
+figure.outdoor.rowing
+figure.outdoor.rowing.circle
+figure.outdoor.rowing.circle.fill
+figure.outdoor.soccer
+figure.outdoor.soccer.circle
+figure.outdoor.soccer.circle.fill
+figure.pickleball
+figure.pickleball.circle
+figure.pickleball.circle.fill
+figure.pilates
+figure.pilates.circle
+figure.pilates.circle.fill
+figure.play
+figure.play.circle
+figure.play.circle.fill
+figure.pool.swim
+figure.pool.swim.circle
+figure.pool.swim.circle.fill
+figure.racquetball
+figure.racquetball.circle
+figure.racquetball.circle.fill
+figure.roll
+figure.roll.circle
+figure.roll.circle.fill
+figure.roll.runningpace
+figure.roll.runningpace.circle
+figure.roll.runningpace.circle.fill
+figure.rolling
+figure.rolling.circle
+figure.rolling.circle.fill
+figure.rower
+figure.rugby
+figure.rugby.circle
+figure.rugby.circle.fill
+figure.run
+figure.run.circle
+figure.run.circle.fill
+figure.run.square.stack
+figure.run.square.stack.fill
+figure.run.treadmill
+figure.run.treadmill.circle
+figure.run.treadmill.circle.fill
+figure.sailing
+figure.sailing.circle
+figure.sailing.circle.fill
+figure.seated.seatbelt
+figure.seated.seatbelt.and.airbag.off
+figure.seated.seatbelt.and.airbag.on
+figure.seated.seatbelt.left.drive.seats.1
+figure.seated.seatbelt.left.drive.seats.1.1
+figure.seated.seatbelt.left.drive.seats.1.1.fill
+figure.seated.seatbelt.left.drive.seats.1.2
+figure.seated.seatbelt.left.drive.seats.1.2.fill
+figure.seated.seatbelt.left.drive.seats.1.fill
+figure.seated.seatbelt.left.drive.seats.2
+figure.seated.seatbelt.left.drive.seats.2.2
+figure.seated.seatbelt.left.drive.seats.2.2.2
+figure.seated.seatbelt.left.drive.seats.2.2.2.fill
+figure.seated.seatbelt.left.drive.seats.2.2.3
+figure.seated.seatbelt.left.drive.seats.2.2.3.fill
+figure.seated.seatbelt.left.drive.seats.2.2.fill
+figure.seated.seatbelt.left.drive.seats.2.3
+figure.seated.seatbelt.left.drive.seats.2.3.2
+figure.seated.seatbelt.left.drive.seats.2.3.2.fill
+figure.seated.seatbelt.left.drive.seats.2.3.3
+figure.seated.seatbelt.left.drive.seats.2.3.3.fill
+figure.seated.seatbelt.left.drive.seats.2.3.fill
+figure.seated.seatbelt.left.drive.seats.2.fill
+figure.seated.seatbelt.left.drive.seats.3
+figure.seated.seatbelt.left.drive.seats.3.3
+figure.seated.seatbelt.left.drive.seats.3.3.3
+figure.seated.seatbelt.left.drive.seats.3.3.3.fill
+figure.seated.seatbelt.left.drive.seats.3.3.fill
+figure.seated.seatbelt.left.drive.seats.3.fill
+figure.seated.side
+figure.seated.side.air.distribution.lower
+figure.seated.side.air.distribution.middle
+figure.seated.side.air.distribution.middle.and.lower
+figure.seated.side.air.distribution.middle.and.lower.angled
+figure.seated.side.air.distribution.upper
+figure.seated.side.air.distribution.upper.angled.and.lower.angled
+figure.seated.side.air.distribution.upper.angled.and.middle
+figure.seated.side.air.distribution.upper.angled.and.middle.and.lower.angled
+figure.seated.side.air.lower
+figure.seated.side.air.upper
+figure.seated.side.air.upper.and.lower
+figure.seated.side.air.windshield
+figure.seated.side.airbag.off
+figure.seated.side.airbag.off.2
+figure.seated.side.airbag.on
+figure.seated.side.airbag.on.2
+figure.seated.side.automatic
+figure.seated.side.left
+figure.seated.side.left.air.distribution.indirect
+figure.seated.side.left.air.distribution.lower
+figure.seated.side.left.air.distribution.lower.angled.and.upper.angled
+figure.seated.side.left.air.distribution.middle
+figure.seated.side.left.air.distribution.middle.and.lower
+figure.seated.side.left.air.distribution.middle.and.lower.angled
+figure.seated.side.left.air.distribution.upper
+figure.seated.side.left.air.distribution.upper.and.middle.and.lower
+figure.seated.side.left.air.distribution.upper.angled.and.dottedline.and.lower.angled
+figure.seated.side.left.air.distribution.upper.angled.and.lower.angled
+figure.seated.side.left.air.distribution.upper.angled.and.middle
+figure.seated.side.left.air.distribution.upper.angled.and.middle.and.lower.angled
+figure.seated.side.left.airbag.off
+figure.seated.side.left.airbag.off.2
+figure.seated.side.left.airbag.on
+figure.seated.side.left.airbag.on.2
+figure.seated.side.left.automatic
+figure.seated.side.left.fan
+figure.seated.side.left.steeringwheel
+figure.seated.side.left.windshield.front.and.heat.waves
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.lower
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.middle
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.middle.and.lower
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.upper
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.upper.and.lower
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.upper.and.middle
+figure.seated.side.left.windshield.front.and.heat.waves.air.distribution.upper.and.middle.and.lower
+figure.seated.side.right
+figure.seated.side.right.air.distribution.indirect
+figure.seated.side.right.air.distribution.lower
+figure.seated.side.right.air.distribution.lower.angled.and.upper.angled
+figure.seated.side.right.air.distribution.middle
+figure.seated.side.right.air.distribution.middle.and.lower
+figure.seated.side.right.air.distribution.middle.and.lower.angled
+figure.seated.side.right.air.distribution.upper
+figure.seated.side.right.air.distribution.upper.and.middle.and.lower
+figure.seated.side.right.air.distribution.upper.angled.and.dottedline.and.lower.angled
+figure.seated.side.right.air.distribution.upper.angled.and.lower.angled
+figure.seated.side.right.air.distribution.upper.angled.and.middle
+figure.seated.side.right.air.distribution.upper.angled.and.middle.and.lower.angled
+figure.seated.side.right.airbag.off
+figure.seated.side.right.airbag.off.2
+figure.seated.side.right.airbag.on
+figure.seated.side.right.airbag.on.2
+figure.seated.side.right.automatic
+figure.seated.side.right.child.lap
+figure.seated.side.right.fan
+figure.seated.side.right.steeringwheel
+figure.seated.side.right.windshield.front.and.heat.waves
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.lower
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.middle
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.middle.and.lower
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.upper
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.upper.and.lower
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.upper.and.middle
+figure.seated.side.right.windshield.front.and.heat.waves.air.distribution.upper.and.middle.and.lower
+figure.seated.side.windshield.front.and.heat.waves
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.lower
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.middle
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.middle.and.lower
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.upper
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.upper.and.lower
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.upper.and.middle
+figure.seated.side.windshield.front.and.heat.waves.air.distribution.upper.and.middle.and.lower
+figure.skateboarding
+figure.skateboarding.circle
+figure.skateboarding.circle.fill
+figure.skating
+figure.skiing.crosscountry
+figure.skiing.crosscountry.circle
+figure.skiing.crosscountry.circle.fill
+figure.skiing.downhill
+figure.skiing.downhill.circle
+figure.skiing.downhill.circle.fill
+figure.snowboarding
+figure.snowboarding.circle
+figure.snowboarding.circle.fill
+figure.soccer
+figure.socialdance
+figure.socialdance.circle
+figure.socialdance.circle.fill
+figure.softball
+figure.softball.circle
+figure.softball.circle.fill
+figure.squash
+figure.squash.circle
+figure.squash.circle.fill
+figure.stair.stepper
+figure.stair.stepper.circle
+figure.stair.stepper.circle.fill
+figure.stairs
+figure.stairs.circle
+figure.stairs.circle.fill
+figure.stand
+figure.stand.dress
+figure.stand.dress.line.vertical.figure
+figure.stand.line.dotted.figure.stand
+figure.step.training
+figure.step.training.circle
+figure.step.training.circle.fill
+figure.strengthtraining.functional
+figure.strengthtraining.functional.circle
+figure.strengthtraining.functional.circle.fill
+figure.strengthtraining.traditional
+figure.strengthtraining.traditional.circle
+figure.strengthtraining.traditional.circle.fill
+figure.surfing
+figure.surfing.circle
+figure.surfing.circle.fill
+figure.table.tennis
+figure.table.tennis.circle
+figure.table.tennis.circle.fill
+figure.taichi
+figure.taichi.circle
+figure.taichi.circle.fill
+figure.tennis
+figure.tennis.circle
+figure.tennis.circle.fill
+figure.track.and.field
+figure.track.and.field.circle
+figure.track.and.field.circle.fill
+figure.volleyball
+figure.volleyball.circle
+figure.volleyball.circle.fill
+figure.walk
+figure.walk.arrival
+figure.walk.circle
+figure.walk.circle.fill
+figure.walk.departure
+figure.walk.diamond
+figure.walk.diamond.fill
+figure.walk.motion
+figure.walk.motion.trianglebadge.exclamationmark
+figure.walk.suitcase.rolling
+figure.walk.suitcase.rolling.circle
+figure.walk.suitcase.rolling.circle.fill
+figure.walk.treadmill
+figure.walk.treadmill.circle
+figure.walk.treadmill.circle.fill
+figure.walk.triangle
+figure.walk.triangle.fill
+figure.water.fitness
+figure.water.fitness.circle
+figure.water.fitness.circle.fill
+figure.waterpolo
+figure.waterpolo.circle
+figure.waterpolo.circle.fill
+figure.wave
+figure.wave.circle
+figure.wave.circle.fill
+figure.wrestling
+figure.wrestling.circle
+figure.wrestling.circle.fill
+figure.yoga
+figure.yoga.circle
+figure.yoga.circle.fill
+filemenu.and.cursorarrow
+filemenu.and.pointer.arrow
+filemenu.and.selection
+film
+film.circle
+film.circle.fill
+film.fill
+film.stack
+film.stack.fill
+finder
+fire.extinguisher
+fire.extinguisher.fill
+fireplace
+fireplace.fill
+firewall
+firewall.fill
+fireworks
+fish
+fish.circle
+fish.circle.fill
+fish.fill
+flag
+flag.2.crossed
+flag.2.crossed.circle
+flag.2.crossed.circle.fill
+flag.2.crossed.fill
+flag.and.flag.filled.crossed
+flag.badge.ellipsis
+flag.badge.ellipsis.fill
+flag.checkered
+flag.checkered.2.crossed
+flag.checkered.circle
+flag.checkered.circle.fill
+flag.circle
+flag.circle.fill
+flag.fill
+flag.filled.and.flag.crossed
+flag.pattern.checkered
+flag.pattern.checkered.2.crossed
+flag.pattern.checkered.circle
+flag.pattern.checkered.circle.fill
+flag.pattern.checkered.lc
+flag.slash
+flag.slash.circle
+flag.slash.circle.fill
+flag.slash.fill
+flag.square
+flag.square.fill
+flame
+flame.circle
+flame.circle.fill
+flame.fill
+flame.gauge.open
+flashlight.off.circle
+flashlight.off.circle.fill
+flashlight.off.fill
+flashlight.on.circle
+flashlight.on.circle.fill
+flashlight.on.fill
+flashlight.slash
+flashlight.slash.circle
+flashlight.slash.circle.fill
+flask
+flask.fill
+fleuron
+fleuron.fill
+flip.horizontal
+flip.horizontal.fill
+flipphone
+florinsign
+florinsign.arrow.circlepath
+florinsign.arrow.trianglehead.counterclockwise.rotate.90
+florinsign.bank.building
+florinsign.bank.building.fill
+florinsign.circle
+florinsign.circle.fill
+florinsign.gauge.chart.lefthalf.righthalf
+florinsign.gauge.chart.leftthird.topthird.rightthird
+florinsign.ring
+florinsign.ring.dashed
+florinsign.square
+florinsign.square.fill
+flowchart
+flowchart.fill
+fluid.batteryblock
+fluid.brakesignal
+fluid.coolant
+fluid.transmission
+fn
+folder
+folder.badge.gear
+folder.badge.gearshape
+folder.badge.minus
+folder.badge.person.crop
+folder.badge.plus
+folder.badge.questionmark
+folder.circle
+folder.circle.fill
+folder.fill
+folder.fill.badge.gear
+folder.fill.badge.gearshape
+folder.fill.badge.minus
+folder.fill.badge.person.crop
+folder.fill.badge.plus
+folder.fill.badge.questionmark
+football
+football.circle
+football.circle.fill
+football.fill
+fork.knife
+fork.knife.circle
+fork.knife.circle.fill
+formfitting.gamecontroller
+formfitting.gamecontroller.fill
+forward
+forward.circle
+forward.circle.fill
+forward.end
+forward.end.alt
+forward.end.alt.fill
+forward.end.circle
+forward.end.circle.fill
+forward.end.fill
+forward.fill
+forward.frame
+forward.frame.fill
+fossil.shell
+fossil.shell.fill
+francsign
+francsign.arrow.circlepath
+francsign.arrow.trianglehead.counterclockwise.rotate.90
+francsign.bank.building
+francsign.bank.building.fill
+francsign.circle
+francsign.circle.fill
+francsign.gauge.chart.lefthalf.righthalf
+francsign.gauge.chart.leftthird.topthird.rightthird
+francsign.ring
+francsign.ring.dashed
+francsign.square
+francsign.square.fill
+frying.pan
+frying.pan.fill
+fuel.filter.water
+fuelpump
+fuelpump.and.filter
+fuelpump.arrowtriangle.left
+fuelpump.arrowtriangle.left.fill
+fuelpump.arrowtriangle.right
+fuelpump.arrowtriangle.right.fill
+fuelpump.circle
+fuelpump.circle.fill
+fuelpump.exclamationmark
+fuelpump.exclamationmark.fill
+fuelpump.fill
+fuelpump.slash
+fuelpump.slash.fill
+fuelpump.thermometer
+fuelpump.thermometer.fill
+function
+fx
+g.circle
+g.circle.fill
+g.square
+g.square.fill
+gamecontroller
+gamecontroller.circle
+gamecontroller.circle.fill
+gamecontroller.fill
+gauge
+gauge.badge.minus
+gauge.badge.plus
+gauge.chart.lefthalf.righthalf
+gauge.chart.leftthird.topthird.rightthird
+gauge.high
+gauge.low
+gauge.medium
+gauge.medium.badge.minus
+gauge.medium.badge.plus
+gauge.open
+gauge.open.righthalf.dotted.with.needle.and.arrow.trianglehead.backward
+gauge.open.with.lines.needle.33percent
+gauge.open.with.lines.needle.33percent.and.arrow.trianglehead.from.0percent.to.50percent
+gauge.open.with.lines.needle.33percent.and.arrowtriangle
+gauge.open.with.lines.needle.33percent.and.arrowtriangle.from.0percent.to.50percent
+gauge.open.with.lines.needle.67percent.and.arrowtriangle
+gauge.open.with.lines.needle.67percent.and.arrowtriangle.and.car
+gauge.open.with.lines.needle.84percent.exclamation
+gauge.with.dots.needle.0percent
+gauge.with.dots.needle.100percent
+gauge.with.dots.needle.33percent
+gauge.with.dots.needle.50percent
+gauge.with.dots.needle.67percent
+gauge.with.dots.needle.bottom.0percent
+gauge.with.dots.needle.bottom.100percent
+gauge.with.dots.needle.bottom.50percent
+gauge.with.dots.needle.bottom.50percent.badge.minus
+gauge.with.dots.needle.bottom.50percent.badge.plus
+gauge.with.needle
+gauge.with.needle.fill
+gear
+gear.badge
+gear.badge.checkmark
+gear.badge.questionmark
+gear.badge.xmark
+gear.circle
+gear.circle.fill
+gearshape
+gearshape.2
+gearshape.2.fill
+gearshape.arrow.triangle.2.circlepath
+gearshape.arrow.trianglehead.2.clockwise.rotate.90
+gearshape.circle
+gearshape.circle.fill
+gearshape.fill
+gearshift.layout.sixspeed
+gift
+gift.circle
+gift.circle.fill
+gift.fill
+giftcard
+giftcard.fill
+globe
+globe.americas
+globe.americas.fill
+globe.asia.australia
+globe.asia.australia.fill
+globe.badge.chevron.backward
+globe.badge.clock
+globe.badge.clock.fill
+globe.central.south.asia
+globe.central.south.asia.fill
+globe.desk
+globe.desk.fill
+globe.europe.africa
+globe.europe.africa.fill
+globe.fill
+glowplug
+gobackward
+gobackward.10
+gobackward.15
+gobackward.30
+gobackward.45
+gobackward.5
+gobackward.60
+gobackward.75
+gobackward.90
+gobackward.minus
+goforward
+goforward.10
+goforward.15
+goforward.30
+goforward.45
+goforward.5
+goforward.60
+goforward.75
+goforward.90
+goforward.plus
+graduationcap
+graduationcap.circle
+graduationcap.circle.fill
+graduationcap.fill
+graph.2d
+graph.3d
+greaterthan
+greaterthan.circle
+greaterthan.circle.fill
+greaterthan.square
+greaterthan.square.fill
+greaterthanorequalto
+greaterthanorequalto.circle
+greaterthanorequalto.circle.fill
+greaterthanorequalto.square
+greaterthanorequalto.square.fill
+greetingcard
+greetingcard.fill
+grid
+grid.circle
+grid.circle.fill
+guaranisign
+guaranisign.arrow.circlepath
+guaranisign.arrow.trianglehead.counterclockwise.rotate.90
+guaranisign.bank.building
+guaranisign.bank.building.fill
+guaranisign.circle
+guaranisign.circle.fill
+guaranisign.gauge.chart.lefthalf.righthalf
+guaranisign.gauge.chart.leftthird.topthird.rightthird
+guaranisign.ring
+guaranisign.ring.dashed
+guaranisign.square
+guaranisign.square.fill
+guidepoint.horizontal
+guidepoint.vertical
+guidepoint.vertical.arrowtriangle.forward
+guidepoint.vertical.numbers
+guitars
+guitars.fill
+gym.bag
+gym.bag.fill
+gyroscope
+h.circle
+h.circle.fill
+h.square
+h.square.fill
+h.square.fill.on.square.fill
+h.square.on.square
+h.square.on.square.fill
+hammer
+hammer.circle
+hammer.circle.fill
+hammer.fill
+hand.app
+hand.app.fill
+hand.draw
+hand.draw.badge.ellipsis
+hand.draw.badge.ellipsis.fill
+hand.draw.fill
+hand.palm.facing
+hand.palm.facing.fill
+hand.pinch
+hand.pinch.fill
+hand.point.down
+hand.point.down.fill
+hand.point.left
+hand.point.left.fill
+hand.point.right
+hand.point.right.fill
+hand.point.up
+hand.point.up.braille
+hand.point.up.braille.badge.ellipsis
+hand.point.up.braille.badge.ellipsis.fill
+hand.point.up.braille.fill
+hand.point.up.fill
+hand.point.up.left
+hand.point.up.left.and.text
+hand.point.up.left.and.text.fill
+hand.point.up.left.fill
+hand.raised
+hand.raised.app
+hand.raised.app.fill
+hand.raised.brakesignal
+hand.raised.brakesignal.slash
+hand.raised.circle
+hand.raised.circle.fill
+hand.raised.fill
+hand.raised.fingers.spread
+hand.raised.fingers.spread.fill
+hand.raised.palm.facing
+hand.raised.palm.facing.fill
+hand.raised.slash
+hand.raised.slash.fill
+hand.raised.square
+hand.raised.square.fill
+hand.raised.square.on.square
+hand.raised.square.on.square.fill
+hand.rays
+hand.rays.fill
+hand.tap
+hand.tap.fill
+hand.thumbsdown
+hand.thumbsdown.circle
+hand.thumbsdown.circle.fill
+hand.thumbsdown.fill
+hand.thumbsdown.filled.hand.thumbsup
+hand.thumbsdown.hand.thumbsup
+hand.thumbsdown.hand.thumbsup.fill
+hand.thumbsdown.hand.thumbsup.filled
+hand.thumbsdown.slash
+hand.thumbsdown.slash.fill
+hand.thumbsup
+hand.thumbsup.circle
+hand.thumbsup.circle.fill
+hand.thumbsup.fill
+hand.thumbsup.slash
+hand.thumbsup.slash.fill
+hand.wave
+hand.wave.fill
+handbag
+handbag.circle
+handbag.circle.fill
+handbag.fill
+handbag.sensor.tag.radiowaves.left.and.right
+handbag.sensor.tag.radiowaves.left.and.right.fill
+hands.and.sparkles
+hands.and.sparkles.fill
+hands.clap
+hands.clap.fill
+hands.sparkles
+hands.sparkles.fill
+hanger
+hare
+hare.circle
+hare.circle.fill
+hare.fill
+hat.cap
+hat.cap.fill
+hat.widebrim
+hat.widebrim.fill
+hazardsign
+hazardsign.fill
+head.profile.arrow.forward.and.vision.pro
+head.profile.arrow.forward.and.visionpro
+head.profile.vision.pro.remove
+headlight.daytime
+headlight.daytime.fill
+headlight.fog
+headlight.fog.fill
+headlight.high.beam
+headlight.high.beam.fill
+headlight.low.beam
+headlight.low.beam.fill
+headphones
+headphones.circle
+headphones.circle.fill
+headphones.dots
+headphones.over.ear
+headphones.sensor.tag.radiowaves.left.and.right
+headphones.sensor.tag.radiowaves.left.and.right.fill
+headphones.slash
+headset
+headset.circle
+headset.circle.fill
+hearingaid.ear
+hearingdevice.and.signal.meter
+hearingdevice.and.signal.meter.fill
+hearingdevice.ear
+hearingdevice.ear.fill
+heart
+heart.badge.bolt
+heart.badge.bolt.fill
+heart.badge.bolt.slash
+heart.badge.bolt.slash.fill
+heart.circle
+heart.circle.fill
+heart.fill
+heart.gauge.open
+heart.rectangle
+heart.rectangle.fill
+heart.slash
+heart.slash.circle
+heart.slash.circle.fill
+heart.slash.fill
+heart.square
+heart.square.fill
+heart.text.clipboard
+heart.text.clipboard.fill
+heart.text.square
+heart.text.square.fill
+heat.element.windshield
+heat.waves
+heat.waves.and.fan
+heat.waves.circle
+heat.waves.circle.fill
+heat.waves.gauge.open
+heater.vertical
+heater.vertical.fill
+helm
+helmet
+helmet.fill
+hexagon
+hexagon.bottomhalf.filled
+hexagon.fill
+hexagon.lefthalf.filled
+hexagon.righthalf.filled
+hexagon.tophalf.filled
+hifireceiver
+hifireceiver.fill
+hifispeaker
+hifispeaker.2
+hifispeaker.2.badge.minus
+hifispeaker.2.badge.minus.fill
+hifispeaker.2.badge.plus
+hifispeaker.2.badge.plus.fill
+hifispeaker.2.fill
+hifispeaker.and.appletv
+hifispeaker.and.appletv.fill
+hifispeaker.and.homepod
+hifispeaker.and.homepod.badge.minus
+hifispeaker.and.homepod.badge.minus.fill
+hifispeaker.and.homepod.badge.plus
+hifispeaker.and.homepod.badge.plus.fill
+hifispeaker.and.homepod.fill
+hifispeaker.and.homepod.mini
+hifispeaker.and.homepod.mini.badge.minus
+hifispeaker.and.homepod.mini.badge.minus.fill
+hifispeaker.and.homepod.mini.badge.plus
+hifispeaker.and.homepod.mini.badge.plus.fill
+hifispeaker.and.homepod.mini.fill
+hifispeaker.and.homepodmini
+hifispeaker.and.homepodmini.fill
+hifispeaker.arrow.forward
+hifispeaker.arrow.forward.fill
+hifispeaker.badge.minus
+hifispeaker.badge.minus.fill
+hifispeaker.badge.plus
+hifispeaker.badge.plus.fill
+hifispeaker.fill
+highlighter
+highlighter.badge.ellipsis
+hockey.puck
+hockey.puck.circle
+hockey.puck.circle.fill
+hockey.puck.fill
+hold.brakesignal
+homekit
+homepod
+homepod.2
+homepod.2.badge.minus
+homepod.2.badge.minus.fill
+homepod.2.badge.plus
+homepod.2.badge.plus.fill
+homepod.2.fill
+homepod.and.appletv
+homepod.and.appletv.fill
+homepod.and.homepod.mini
+homepod.and.homepod.mini.badge.minus
+homepod.and.homepod.mini.badge.minus.fill
+homepod.and.homepod.mini.badge.plus
+homepod.and.homepod.mini.badge.plus.fill
+homepod.and.homepod.mini.fill
+homepod.and.homepodmini
+homepod.and.homepodmini.fill
+homepod.arrow.forward
+homepod.arrow.forward.fill
+homepod.badge.checkmark
+homepod.badge.checkmark.fill
+homepod.badge.minus
+homepod.badge.minus.fill
+homepod.badge.plus
+homepod.badge.plus.fill
+homepod.fill
+homepod.mini
+homepod.mini.2
+homepod.mini.2.badge.minus
+homepod.mini.2.badge.minus.fill
+homepod.mini.2.badge.plus
+homepod.mini.2.badge.plus.fill
+homepod.mini.2.fill
+homepod.mini.and.appletv
+homepod.mini.and.appletv.fill
+homepod.mini.arrow.forward
+homepod.mini.arrow.forward.fill
+homepod.mini.badge.checkmark
+homepod.mini.badge.checkmark.fill
+homepod.mini.badge.minus
+homepod.mini.badge.minus.fill
+homepod.mini.badge.plus
+homepod.mini.badge.plus.fill
+homepod.mini.fill
+homepodmini
+homepodmini.2
+homepodmini.2.fill
+homepodmini.and.appletv
+homepodmini.and.appletv.fill
+homepodmini.fill
+horn
+horn.blast
+horn.blast.fill
+horn.fill
+hourglass
+hourglass.and.lock
+hourglass.badge.eye
+hourglass.badge.lock
+hourglass.badge.plus
+hourglass.bottomhalf.fill
+hourglass.bottomhalf.filled
+hourglass.circle
+hourglass.circle.fill
+hourglass.tophalf.fill
+hourglass.tophalf.filled
+house
+house.and.flag
+house.and.flag.circle
+house.and.flag.circle.fill
+house.and.flag.fill
+house.badge.exclamationmark
+house.badge.exclamationmark.fill
+house.badge.wifi
+house.badge.wifi.fill
+house.circle
+house.circle.fill
+house.fill
+house.lodge
+house.lodge.circle
+house.lodge.circle.fill
+house.lodge.fill
+house.slash
+house.slash.fill
+hryvniasign
+hryvniasign.arrow.circlepath
+hryvniasign.arrow.trianglehead.counterclockwise.rotate.90
+hryvniasign.bank.building
+hryvniasign.bank.building.fill
+hryvniasign.circle
+hryvniasign.circle.fill
+hryvniasign.gauge.chart.lefthalf.righthalf
+hryvniasign.gauge.chart.leftthird.topthird.rightthird
+hryvniasign.ring
+hryvniasign.ring.dashed
+hryvniasign.square
+hryvniasign.square.fill
+humidifier
+humidifier.and.droplets
+humidifier.and.droplets.fill
+humidifier.and.ellipsis
+humidifier.and.ellipsis.fill
+humidifier.fill
+humidity
+humidity.fill
+hurricane
+hurricane.circle
+hurricane.circle.fill
+hydrogen
+hydrogen.circle
+hydrogen.circle.fill
+hydrogen.square
+hydrogen.square.fill
+i.circle
+i.circle.fill
+i.square
+i.square.fill
+icloud
+icloud.and.arrow.down
+icloud.and.arrow.down.fill
+icloud.and.arrow.up
+icloud.and.arrow.up.fill
+icloud.circle
+icloud.circle.fill
+icloud.dashed
+icloud.fill
+icloud.slash
+icloud.slash.fill
+icloud.square
+icloud.square.fill
+increase.indent
+increase.quotelevel
+indianrupeesign
+indianrupeesign.arrow.circlepath
+indianrupeesign.arrow.trianglehead.counterclockwise.rotate.90
+indianrupeesign.bank.building
+indianrupeesign.bank.building.fill
+indianrupeesign.circle
+indianrupeesign.circle.fill
+indianrupeesign.gauge.chart.lefthalf.righthalf
+indianrupeesign.gauge.chart.leftthird.topthird.rightthird
+indianrupeesign.ring
+indianrupeesign.ring.dashed
+indianrupeesign.square
+indianrupeesign.square.fill
+infinity
+infinity.circle
+infinity.circle.fill
+info
+info.bubble
+info.bubble.fill
+info.circle
+info.circle.fill
+info.circle.text.page
+info.circle.text.page.fill
+info.square
+info.square.fill
+info.triangle
+info.triangle.fill
+info.windshield
+inhaler
+inhaler.fill
+inset.filled.applewatch.case
+inset.filled.bottomhalf.rectangle
+inset.filled.bottomhalf.rectangle.portrait
+inset.filled.bottomhalf.tophalf.rectangle
+inset.filled.bottomleading.bottomtrailing.rectangle
+inset.filled.bottomleading.rectangle
+inset.filled.bottomleading.rectangle.portrait
+inset.filled.bottomleft.bottomright.rectangle
+inset.filled.bottomleft.rectangle
+inset.filled.bottomleft.rectangle.portrait
+inset.filled.bottomright.rectangle
+inset.filled.bottomright.rectangle.portrait
+inset.filled.bottomthird.rectangle
+inset.filled.bottomthird.rectangle.portrait
+inset.filled.bottomthird.square
+inset.filled.bottomtrailing.rectangle
+inset.filled.bottomtrailing.rectangle.portrait
+inset.filled.capsule
+inset.filled.capsule.portrait
+inset.filled.center.rectangle
+inset.filled.center.rectangle.badge.plus
+inset.filled.center.rectangle.portrait
+inset.filled.circle
+inset.filled.circle.dashed
+inset.filled.circle.slash
+inset.filled.diamond
+inset.filled.leadinghalf.arrow.leading.rectangle
+inset.filled.leadinghalf.rectangle
+inset.filled.leadinghalf.rectangle.portrait
+inset.filled.leadinghalf.toptrailing.bottomtrailing.rectangle
+inset.filled.leadinghalf.trailinghalf.rectangle
+inset.filled.leadingthird.rectangle
+inset.filled.leadingthird.rectangle.portrait
+inset.filled.leadingthird.square
+inset.filled.lefthalf.arrow.left.rectangle
+inset.filled.lefthalf.rectangle
+inset.filled.lefthalf.rectangle.portrait
+inset.filled.lefthalf.righthalf.rectangle
+inset.filled.lefthalf.topright.bottomright.rectangle
+inset.filled.leftthird.middlethird.rightthird.rectangle
+inset.filled.leftthird.rectangle
+inset.filled.leftthird.rectangle.portrait
+inset.filled.leftthird.square
+inset.filled.oval
+inset.filled.oval.portrait
+inset.filled.pano
+inset.filled.rectangle
+inset.filled.rectangle.and.cursorarrow
+inset.filled.rectangle.and.person.filled
+inset.filled.rectangle.and.person.filled.circle
+inset.filled.rectangle.and.person.filled.circle.fill
+inset.filled.rectangle.and.person.filled.slash
+inset.filled.rectangle.and.pointer.arrow
+inset.filled.rectangle.badge.record
+inset.filled.rectangle.on.rectangle
+inset.filled.rectangle.portrait
+inset.filled.righthalf.arrow.right.rectangle
+inset.filled.righthalf.lefthalf.rectangle
+inset.filled.righthalf.rectangle
+inset.filled.righthalf.rectangle.portrait
+inset.filled.rightthird.rectangle
+inset.filled.rightthird.rectangle.portrait
+inset.filled.rightthird.square
+inset.filled.square
+inset.filled.square.dashed
+inset.filled.tophalf.bottomhalf.rectangle
+inset.filled.tophalf.bottomleft.bottomright.rectangle
+inset.filled.tophalf.rectangle
+inset.filled.tophalf.rectangle.portrait
+inset.filled.topleading.bottomleading.trailinghalf.rectangle
+inset.filled.topleading.rectangle
+inset.filled.topleading.rectangle.portrait
+inset.filled.topleft.bottomleft.righthalf.rectangle
+inset.filled.topleft.rectangle
+inset.filled.topleft.rectangle.portrait
+inset.filled.topleft.topright.bottomhalf.rectangle
+inset.filled.topleft.topright.bottomleft.bottomright.rectangle
+inset.filled.topright.rectangle
+inset.filled.topright.rectangle.portrait
+inset.filled.topthird.middlethird.bottomthird.rectangle
+inset.filled.topthird.rectangle
+inset.filled.topthird.rectangle.portrait
+inset.filled.topthird.square
+inset.filled.toptrailing.rectangle
+inset.filled.toptrailing.rectangle.portrait
+inset.filled.trailinghalf.arrow.trailing.rectangle
+inset.filled.trailinghalf.leadinghalf.rectangle
+inset.filled.trailinghalf.rectangle
+inset.filled.trailinghalf.rectangle.portrait
+inset.filled.trailingthird.rectangle
+inset.filled.trailingthird.rectangle.portrait
+inset.filled.trailingthird.square
+inset.filled.triangle
+inset.filled.tv
+internaldrive
+internaldrive.fill
+ipad
+ipad.and.arrow.forward
+ipad.and.iphone
+ipad.and.iphone.slash
+ipad.badge.checkmark
+ipad.badge.exclamationmark
+ipad.badge.location
+ipad.badge.play
+ipad.case
+ipad.case.and.iphone.case
+ipad.gen1
+ipad.gen1.badge.exclamationmark
+ipad.gen1.badge.location
+ipad.gen1.badge.play
+ipad.gen1.crop.homebutton.circle
+ipad.gen1.landscape
+ipad.gen1.landscape.badge.exclamationmark
+ipad.gen1.landscape.badge.location
+ipad.gen1.landscape.badge.play
+ipad.gen1.landscape.slash
+ipad.gen1.sizes
+ipad.gen1.slash
+ipad.gen2
+ipad.gen2.badge.exclamationmark
+ipad.gen2.badge.location
+ipad.gen2.badge.play
+ipad.gen2.landscape
+ipad.gen2.landscape.badge.exclamationmark
+ipad.gen2.landscape.badge.location
+ipad.gen2.landscape.badge.play
+ipad.gen2.landscape.slash
+ipad.gen2.sizes
+ipad.gen2.slash
+ipad.homebutton
+ipad.homebutton.badge.play
+ipad.homebutton.landscape
+ipad.homebutton.landscape.badge.play
+ipad.landscape
+ipad.landscape.and.applewatch
+ipad.landscape.and.iphone
+ipad.landscape.and.iphone.slash
+ipad.landscape.and.ipod
+ipad.landscape.badge.exclamationmark
+ipad.landscape.badge.location
+ipad.landscape.badge.play
+ipad.rear.camera
+ipad.sizes
+iphone
+iphone.and.arrow.forward
+iphone.and.arrow.forward.inward
+iphone.and.arrow.forward.outward
+iphone.and.arrow.left.and.arrow.right
+iphone.and.arrow.left.and.arrow.right.inward
+iphone.and.arrow.right.inward
+iphone.and.arrow.right.outward
+iphone.and.ipod
+iphone.and.vision.pro
+iphone.app.switcher
+iphone.badge.checkmark
+iphone.badge.exclamationmark
+iphone.badge.location
+iphone.badge.play
+iphone.case
+iphone.circle
+iphone.circle.fill
+iphone.crop.circle
+iphone.dock.motorized.viewfinder
+iphone.gen1
+iphone.gen1.and.arrow.left
+iphone.gen1.badge.exclamationmark
+iphone.gen1.badge.location
+iphone.gen1.badge.play
+iphone.gen1.circle
+iphone.gen1.circle.fill
+iphone.gen1.crop.circle
+iphone.gen1.crop.homebutton.circle
+iphone.gen1.landscape
+iphone.gen1.landscape.slash
+iphone.gen1.motion
+iphone.gen1.radiowaves.left.and.right
+iphone.gen1.radiowaves.left.and.right.circle
+iphone.gen1.radiowaves.left.and.right.circle.fill
+iphone.gen1.sizes
+iphone.gen1.slash
+iphone.gen1.slash.circle
+iphone.gen1.slash.circle.fill
+iphone.gen2
+iphone.gen2.and.arrow.left.and.arrow.right.inward
+iphone.gen2.badge.exclamationmark
+iphone.gen2.badge.location
+iphone.gen2.badge.play
+iphone.gen2.circle
+iphone.gen2.circle.fill
+iphone.gen2.crop.circle
+iphone.gen2.landscape
+iphone.gen2.landscape.slash
+iphone.gen2.motion
+iphone.gen2.radiowaves.left.and.right
+iphone.gen2.radiowaves.left.and.right.circle
+iphone.gen2.radiowaves.left.and.right.circle.fill
+iphone.gen2.sizes
+iphone.gen2.slash
+iphone.gen2.slash.circle
+iphone.gen2.slash.circle.fill
+iphone.gen3
+iphone.gen3.and.arrow.left.and.arrow.right.inward
+iphone.gen3.badge.exclamationmark
+iphone.gen3.badge.location
+iphone.gen3.badge.play
+iphone.gen3.circle
+iphone.gen3.circle.fill
+iphone.gen3.crop.circle
+iphone.gen3.landscape
+iphone.gen3.landscape.slash
+iphone.gen3.motion
+iphone.gen3.radiowaves.left.and.right
+iphone.gen3.radiowaves.left.and.right.circle
+iphone.gen3.radiowaves.left.and.right.circle.fill
+iphone.gen3.sizes
+iphone.gen3.slash
+iphone.gen3.slash.circle
+iphone.gen3.slash.circle.fill
+iphone.homebutton
+iphone.homebutton.badge.play
+iphone.homebutton.circle
+iphone.homebutton.circle.fill
+iphone.homebutton.landscape
+iphone.homebutton.radiowaves.left.and.right
+iphone.homebutton.radiowaves.left.and.right.circle
+iphone.homebutton.radiowaves.left.and.right.circle.fill
+iphone.homebutton.slash
+iphone.homebutton.slash.circle
+iphone.homebutton.slash.circle.fill
+iphone.landscape
+iphone.motion
+iphone.pattern.diagonalline
+iphone.pattern.diagonalline.on.rectangle.portrait.dashed
+iphone.radiowaves.left.and.right
+iphone.radiowaves.left.and.right.circle
+iphone.radiowaves.left.and.right.circle.fill
+iphone.rear.camera
+iphone.sizes
+iphone.slash
+iphone.slash.circle
+iphone.slash.circle.fill
+iphone.smartbatterycase.gen1
+iphone.smartbatterycase.gen2
+ipod
+ipod.and.applewatch
+ipod.and.vision.pro
+ipod.shuffle.gen1
+ipod.shuffle.gen2
+ipod.shuffle.gen3
+ipod.shuffle.gen4
+ipod.touch
+ipod.touch.landscape
+ipod.touch.slash
+ipodshuffle.gen1
+ipodshuffle.gen2
+ipodshuffle.gen3
+ipodshuffle.gen4
+ipodtouch
+ipodtouch.landscape
+ipodtouch.slash
+italic
+ivfluid.bag
+ivfluid.bag.fill
+j.circle
+j.circle.fill
+j.square
+j.square.fill
+j.square.fill.on.square.fill
+j.square.on.square
+j.square.on.square.fill
+jacket
+jacket.circle
+jacket.circle.fill
+jacket.fill
+jacket.sensor.tag.radiowaves.left.and.right
+jacket.sensor.tag.radiowaves.left.and.right.fill
+k
+k.circle
+k.circle.fill
+k.square
+k.square.fill
+kashida.arabic
+key
+key.2.on.ring
+key.2.on.ring.fill
+key.car.radiowaves.forward
+key.car.radiowaves.forward.fill
+key.car.side
+key.car.side.fill
+key.card
+key.card.fill
+key.circle
+key.circle.fill
+key.convertible.side
+key.convertible.side.fill
+key.fill
+key.horizontal
+key.horizontal.fill
+key.icloud
+key.icloud.fill
+key.radiowaves.forward
+key.radiowaves.forward.fill
+key.radiowaves.forward.slash
+key.radiowaves.forward.slash.fill
+key.sensor.tag.radiowaves.left.and.right
+key.sensor.tag.radiowaves.left.and.right.fill
+key.shield
+key.shield.fill
+key.slash
+key.slash.fill
+key.suv.side
+key.suv.side.fill
+key.truck.pickup.side
+key.truck.pickup.side.fill
+key.viewfinder
+keyboard
+keyboard.badge.ellipsis
+keyboard.badge.ellipsis.fill
+keyboard.badge.eye
+keyboard.badge.eye.fill
+keyboard.chevron.compact.down
+keyboard.chevron.compact.down.fill
+keyboard.chevron.compact.left
+keyboard.chevron.compact.left.fill
+keyboard.fill
+keyboard.macwindow
+keyboard.onehanded.left
+keyboard.onehanded.left.fill
+keyboard.onehanded.right
+keyboard.onehanded.right.fill
+kipsign
+kipsign.arrow.circlepath
+kipsign.arrow.trianglehead.counterclockwise.rotate.90
+kipsign.bank.building
+kipsign.bank.building.fill
+kipsign.circle
+kipsign.circle.fill
+kipsign.gauge.chart.lefthalf.righthalf
+kipsign.gauge.chart.leftthird.topthird.rightthird
+kipsign.ring
+kipsign.ring.dashed
+kipsign.square
+kipsign.square.fill
+kph
+kph.circle
+kph.circle.fill
+l.button.roundedbottom.horizontal
+l.button.roundedbottom.horizontal.fill
+l.circle
+l.circle.fill
+l.joystick
+l.joystick.down
+l.joystick.down.fill
+l.joystick.fill
+l.joystick.press.down
+l.joystick.press.down.fill
+l.joystick.tilt.down
+l.joystick.tilt.down.fill
+l.joystick.tilt.left
+l.joystick.tilt.left.fill
+l.joystick.tilt.right
+l.joystick.tilt.right.fill
+l.joystick.tilt.up
+l.joystick.tilt.up.fill
+l.rectangle.roundedbottom
+l.rectangle.roundedbottom.fill
+l.square
+l.square.fill
+l1.button.roundedbottom.horizontal
+l1.button.roundedbottom.horizontal.fill
+l1.circle
+l1.circle.fill
+l1.rectangle.roundedbottom
+l1.rectangle.roundedbottom.fill
+l2.button.angledtop.vertical.left
+l2.button.angledtop.vertical.left.fill
+l2.button.roundedtop.horizontal
+l2.button.roundedtop.horizontal.fill
+l2.circle
+l2.circle.fill
+l2.rectangle.roundedtop
+l2.rectangle.roundedtop.fill
+l3.button.angledbottom.horizontal.left
+l3.button.angledbottom.horizontal.left.fill
+l4.button.horizontal
+l4.button.horizontal.fill
+ladybug
+ladybug.circle
+ladybug.circle.fill
+ladybug.fill
+ladybug.slash
+ladybug.slash.circle
+ladybug.slash.circle.fill
+ladybug.slash.fill
+lamp.ceiling
+lamp.ceiling.fill
+lamp.ceiling.inverse
+lamp.desk
+lamp.desk.fill
+lamp.floor
+lamp.floor.fill
+lamp.table
+lamp.table.fill
+lane
+lanyardcard
+lanyardcard.fill
+laptopcomputer
+laptopcomputer.and.arrow.down
+laptopcomputer.and.ipad
+laptopcomputer.and.iphone
+laptopcomputer.badge.checkmark
+laptopcomputer.slash
+laptopcomputer.trianglebadge.exclamationmark
+largecircle.fill.circle
+larisign
+larisign.arrow.circlepath
+larisign.arrow.trianglehead.counterclockwise.rotate.90
+larisign.bank.building
+larisign.bank.building.fill
+larisign.circle
+larisign.circle.fill
+larisign.gauge.chart.lefthalf.righthalf
+larisign.gauge.chart.leftthird.topthird.rightthird
+larisign.ring
+larisign.ring.dashed
+larisign.square
+larisign.square.fill
+laser.burst
+lasso
+lasso.and.sparkles
+lasso.badge.sparkles
+lasso.sparkles
+latch.2.case
+latch.2.case.fill
+laurel.leading
+laurel.leading.laurel.trailing
+laurel.trailing
+lb.button.roundedbottom.horizontal
+lb.button.roundedbottom.horizontal.fill
+lb.circle
+lb.circle.fill
+lb.rectangle.roundedbottom
+lb.rectangle.roundedbottom.fill
+leaf
+leaf.arrow.circlepath
+leaf.arrow.triangle.circlepath
+leaf.arrow.trianglehead.clockwise
+leaf.circle
+leaf.circle.fill
+leaf.fill
+left
+left.circle
+left.circle.fill
+lessthan
+lessthan.circle
+lessthan.circle.fill
+lessthan.square
+lessthan.square.fill
+lessthanorequalto
+lessthanorequalto.circle
+lessthanorequalto.circle.fill
+lessthanorequalto.square
+lessthanorequalto.square.fill
+level
+level.fill
+licenseplate
+licenseplate.fill
+lifepreserver
+lifepreserver.fill
+light.beacon.max
+light.beacon.max.fill
+light.beacon.min
+light.beacon.min.fill
+light.cylindrical.ceiling
+light.cylindrical.ceiling.fill
+light.cylindrical.ceiling.inverse
+light.max
+light.min
+light.overhead.left
+light.overhead.left.fill
+light.overhead.right
+light.overhead.right.fill
+light.panel
+light.panel.fill
+light.recessed
+light.recessed.3
+light.recessed.3.fill
+light.recessed.3.inverse
+light.recessed.fill
+light.recessed.inverse
+light.ribbon
+light.ribbon.fill
+light.strip.2
+light.strip.2.fill
+lightbulb
+lightbulb.2
+lightbulb.2.fill
+lightbulb.circle
+lightbulb.circle.fill
+lightbulb.fill
+lightbulb.led
+lightbulb.led.fill
+lightbulb.led.wide
+lightbulb.led.wide.fill
+lightbulb.max
+lightbulb.max.fill
+lightbulb.min
+lightbulb.min.badge.exclamationmark
+lightbulb.min.badge.exclamationmark.fill
+lightbulb.min.fill
+lightbulb.slash
+lightbulb.slash.fill
+lightrail
+lightrail.fill
+lightspectrum.horizontal
+lightswitch.off
+lightswitch.off.fill
+lightswitch.off.square
+lightswitch.off.square.fill
+lightswitch.on
+lightswitch.on.fill
+lightswitch.on.square
+lightswitch.on.square.fill
+line.2.horizontal.decrease.circle
+line.2.horizontal.decrease.circle.fill
+line.3.crossed.swirl.circle
+line.3.crossed.swirl.circle.fill
+line.3.horizontal
+line.3.horizontal.button.angledtop.vertical.right
+line.3.horizontal.button.angledtop.vertical.right.fill
+line.3.horizontal.circle
+line.3.horizontal.circle.fill
+line.3.horizontal.decrease
+line.3.horizontal.decrease.circle
+line.3.horizontal.decrease.circle.fill
+line.diagonal
+line.diagonal.arrow
+line.diagonal.trianglehead.up.right
+line.diagonal.trianglehead.up.right.left.down
+line.horizontal.2.decrease.circle
+line.horizontal.2.decrease.circle.fill
+line.horizontal.3
+line.horizontal.3.circle
+line.horizontal.3.circle.fill
+line.horizontal.3.decrease
+line.horizontal.3.decrease.circle
+line.horizontal.3.decrease.circle.fill
+line.horizontal.star.fill.line.horizontal
+lines.measurement.horizontal
+lines.measurement.horizontal.aligned.bottom
+lines.measurement.vertical
+lineweight
+link
+link.badge.plus
+link.circle
+link.circle.fill
+link.icloud
+link.icloud.fill
+lirasign
+lirasign.arrow.circlepath
+lirasign.arrow.trianglehead.counterclockwise.rotate.90
+lirasign.bank.building
+lirasign.bank.building.fill
+lirasign.circle
+lirasign.circle.fill
+lirasign.gauge.chart.lefthalf.righthalf
+lirasign.gauge.chart.leftthird.topthird.rightthird
+lirasign.ring
+lirasign.ring.dashed
+lirasign.square
+lirasign.square.fill
+list.and.film
+list.bullet
+list.bullet.badge.ellipsis
+list.bullet.below.rectangle
+list.bullet.circle
+list.bullet.circle.fill
+list.bullet.clipboard
+list.bullet.clipboard.fill
+list.bullet.indent
+list.bullet.rectangle
+list.bullet.rectangle.fill
+list.bullet.rectangle.portrait
+list.bullet.rectangle.portrait.fill
+list.clipboard
+list.clipboard.fill
+list.dash
+list.dash.badge.ellipsis
+list.dash.header.rectangle
+list.dash.header.rectangle.fill
+list.number
+list.number.badge.ellipsis
+list.star
+list.triangle
+livephoto
+livephoto.badge.a
+livephoto.badge.automatic
+livephoto.play
+livephoto.slash
+lizard
+lizard.circle
+lizard.circle.fill
+lizard.fill
+lm.button.horizontal
+lm.button.horizontal.fill
+location
+location.app
+location.app.fill
+location.circle
+location.circle.fill
+location.fill
+location.fill.viewfinder
+location.magnifyingglass
+location.north
+location.north.circle
+location.north.circle.fill
+location.north.fill
+location.north.line
+location.north.line.fill
+location.slash
+location.slash.circle
+location.slash.circle.fill
+location.slash.fill
+location.square
+location.square.fill
+location.viewfinder
+lock
+lock.app.dashed
+lock.applewatch
+lock.badge.checkmark
+lock.badge.checkmark.fill
+lock.badge.clock
+lock.badge.clock.fill
+lock.badge.xmark
+lock.badge.xmark.fill
+lock.circle
+lock.circle.dotted
+lock.circle.fill
+lock.desktopcomputer
+lock.display
+lock.doc
+lock.doc.fill
+lock.document
+lock.document.fill
+lock.fill
+lock.heart
+lock.heart.fill
+lock.icloud
+lock.icloud.fill
+lock.ipad
+lock.iphone
+lock.laptopcomputer
+lock.open
+lock.open.applewatch
+lock.open.desktopcomputer
+lock.open.display
+lock.open.fill
+lock.open.ipad
+lock.open.iphone
+lock.open.laptopcomputer
+lock.open.rotation
+lock.open.trianglebadge.exclamationmark
+lock.open.trianglebadge.exclamationmark.fill
+lock.rectangle
+lock.rectangle.dashed
+lock.rectangle.fill
+lock.rectangle.on.rectangle
+lock.rectangle.on.rectangle.dashed
+lock.rectangle.on.rectangle.fill
+lock.rectangle.stack
+lock.rectangle.stack.fill
+lock.rotation
+lock.rotation.open
+lock.shield
+lock.shield.fill
+lock.slash
+lock.slash.fill
+lock.square
+lock.square.dashed
+lock.square.fill
+lock.square.stack
+lock.square.stack.fill
+lock.trianglebadge.exclamationmark
+lock.trianglebadge.exclamationmark.fill
+logo.playstation
+logo.xbox
+long.text.page.and.pencil
+long.text.page.and.pencil.fill
+loupe
+lsb.button.angledbottom.horizontal.left
+lsb.button.angledbottom.horizontal.left.fill
+lt.button.roundedtop.horizontal
+lt.button.roundedtop.horizontal.fill
+lt.circle
+lt.circle.fill
+lt.rectangle.roundedtop
+lt.rectangle.roundedtop.fill
+lungs
+lungs.fill
+m.circle
+m.circle.fill
+m.square
+m.square.fill
+m1.button.horizontal
+m1.button.horizontal.fill
+m2.button.horizontal
+m2.button.horizontal.fill
+m3.button.horizontal
+m3.button.horizontal.fill
+m4.button.horizontal
+m4.button.horizontal.fill
+macbook
+macbook.and.applewatch
+macbook.and.ipad
+macbook.and.iphone
+macbook.and.ipod
+macbook.and.vision.pro
+macbook.and.visionpro
+macbook.badge.checkmark
+macbook.badge.exclamationmark
+macbook.badge.shield.checkmark
+macbook.gen1
+macbook.gen1.sizes
+macbook.gen2
+macbook.gen2.sizes
+macbook.sizes
+macbook.slash
+macbook.trianglebadge.exclamationmark
+macmini
+macmini.badge.checkmark
+macmini.badge.checkmark.fill
+macmini.fill
+macmini.gen2
+macmini.gen2.fill
+macmini.gen3
+macmini.gen3.fill
+macpro.gen1
+macpro.gen1.fill
+macpro.gen2
+macpro.gen2.fill
+macpro.gen3
+macpro.gen3.badge.ckeckmark
+macpro.gen3.badge.ckeckmark.fill
+macpro.gen3.fill
+macpro.gen3.server
+macstudio
+macstudio.badge.checkmark
+macstudio.badge.checkmark.fill
+macstudio.fill
+macwindow
+macwindow.and.cursorarrow
+macwindow.and.pointer.arrow
+macwindow.badge.plus
+macwindow.on.rectangle
+macwindow.stack
+magazine
+magazine.fill
+magicmouse
+magicmouse.fill
+magnifyingglass
+magnifyingglass.circle
+magnifyingglass.circle.fill
+magsafe.batterypack
+magsafe.batterypack.fill
+mail
+mail.and.text.magnifyingglass
+mail.fill
+mail.stack
+mail.stack.fill
+malaysianringgitsign
+malaysianringgitsign.arrow.trianglehead.counterclockwise.rotate.90
+malaysianringgitsign.bank.building
+malaysianringgitsign.bank.building.fill
+malaysianringgitsign.circle
+malaysianringgitsign.circle.fill
+malaysianringgitsign.gauge.chart.lefthalf.righthalf
+malaysianringgitsign.gauge.chart.leftthird.topthird.rightthird
+malaysianringgitsign.ring
+malaysianringgitsign.ring.dashed
+malaysianringgitsign.square
+malaysianringgitsign.square.fill
+manatsign
+manatsign.arrow.circlepath
+manatsign.arrow.trianglehead.counterclockwise.rotate.90
+manatsign.bank.building
+manatsign.bank.building.fill
+manatsign.circle
+manatsign.circle.fill
+manatsign.gauge.chart.lefthalf.righthalf
+manatsign.gauge.chart.leftthird.topthird.rightthird
+manatsign.ring
+manatsign.ring.dashed
+manatsign.square
+manatsign.square.fill
+map
+map.circle
+map.circle.fill
+map.fill
+mappin
+mappin.and.ellipse
+mappin.and.ellipse.circle
+mappin.and.ellipse.circle.fill
+mappin.circle
+mappin.circle.fill
+mappin.slash
+mappin.slash.circle
+mappin.slash.circle.fill
+mappin.square
+mappin.square.fill
+matter.logo
+mecca
+medal
+medal.fill
+medal.star
+medal.star.fill
+mediastick
+medical.thermometer
+medical.thermometer.fill
+megaphone
+megaphone.fill
+memories
+memories.badge.checkmark
+memories.badge.minus
+memories.badge.plus
+memories.badge.xmark
+memories.slash
+memorychip
+memorychip.fill
+menubar.arrow.down.rectangle
+menubar.arrow.up.rectangle
+menubar.dock.rectangle
+menubar.dock.rectangle.badge.record
+menubar.rectangle
+menucard
+menucard.fill
+message
+message.and.waveform
+message.and.waveform.fill
+message.badge
+message.badge.circle
+message.badge.circle.fill
+message.badge.fill
+message.badge.filled.fill
+message.badge.waveform
+message.badge.waveform.fill
+message.circle
+message.circle.fill
+message.fill
+metronome
+metronome.fill
+mic
+mic.and.signal.meter
+mic.and.signal.meter.fill
+mic.badge.plus
+mic.badge.xmark
+mic.circle
+mic.circle.fill
+mic.fill
+mic.fill.badge.plus
+mic.fill.badge.xmark
+mic.slash
+mic.slash.circle
+mic.slash.circle.fill
+mic.slash.fill
+mic.square
+mic.square.fill
+microbe
+microbe.circle
+microbe.circle.fill
+microbe.fill
+microphone
+microphone.and.signal.meter
+microphone.and.signal.meter.fill
+microphone.badge.ellipsis
+microphone.badge.ellipsis.fill
+microphone.badge.plus
+microphone.badge.plus.fill
+microphone.badge.xmark
+microphone.badge.xmark.fill
+microphone.circle
+microphone.circle.fill
+microphone.fill
+microphone.slash
+microphone.slash.circle
+microphone.slash.circle.fill
+microphone.slash.fill
+microphone.square
+microphone.square.fill
+microwave
+microwave.fill
+millsign
+millsign.arrow.circlepath
+millsign.arrow.trianglehead.counterclockwise.rotate.90
+millsign.bank.building
+millsign.bank.building.fill
+millsign.circle
+millsign.circle.fill
+millsign.gauge.chart.lefthalf.righthalf
+millsign.gauge.chart.leftthird.topthird.rightthird
+millsign.ring
+millsign.ring.dashed
+millsign.square
+millsign.square.fill
+minus
+minus.arrow.trianglehead.clockwise
+minus.arrow.trianglehead.counterclockwise
+minus.circle
+minus.circle.fill
+minus.diamond
+minus.diamond.fill
+minus.forwardslash.plus
+minus.magnifyingglass
+minus.plus.and.fluid.batteryblock
+minus.plus.batteryblock
+minus.plus.batteryblock.exclamationmark
+minus.plus.batteryblock.exclamationmark.fill
+minus.plus.batteryblock.fill
+minus.plus.batteryblock.slash
+minus.plus.batteryblock.slash.fill
+minus.plus.batteryblock.stack
+minus.plus.batteryblock.stack.arrowtriangle.left
+minus.plus.batteryblock.stack.arrowtriangle.left.fill
+minus.plus.batteryblock.stack.arrowtriangle.right
+minus.plus.batteryblock.stack.arrowtriangle.right.and.arrowtriangle.left
+minus.plus.batteryblock.stack.arrowtriangle.right.and.arrowtriangle.left.fill
+minus.plus.batteryblock.stack.arrowtriangle.right.fill
+minus.plus.batteryblock.stack.exclamationmark
+minus.plus.batteryblock.stack.exclamationmark.fill
+minus.plus.batteryblock.stack.fill
+minus.plus.lines.measurement.horizontal.aligned.bottom
+minus.rectangle
+minus.rectangle.fill
+minus.rectangle.portrait
+minus.rectangle.portrait.fill
+minus.slash.plus
+minus.square
+minus.square.fill
+mirror.side.left
+mirror.side.left.and.arrow.turn.down.right
+mirror.side.left.and.heat.waves
+mirror.side.right
+mirror.side.right.and.arrow.turn.down.left
+mirror.side.right.and.heat.waves
+moon
+moon.circle
+moon.circle.fill
+moon.dust
+moon.dust.circle
+moon.dust.circle.fill
+moon.dust.fill
+moon.fill
+moon.haze
+moon.haze.circle
+moon.haze.circle.fill
+moon.haze.fill
+moon.road.lanes
+moon.stars
+moon.stars.circle
+moon.stars.circle.fill
+moon.stars.fill
+moon.zzz
+moon.zzz.fill
+moonphase.first.quarter
+moonphase.first.quarter.inverse
+moonphase.full.moon
+moonphase.full.moon.inverse
+moonphase.last.quarter
+moonphase.last.quarter.inverse
+moonphase.new.moon
+moonphase.new.moon.inverse
+moonphase.waning.crescent
+moonphase.waning.crescent.inverse
+moonphase.waning.gibbous
+moonphase.waning.gibbous.inverse
+moonphase.waxing.crescent
+moonphase.waxing.crescent.inverse
+moonphase.waxing.gibbous
+moonphase.waxing.gibbous.inverse
+moonrise
+moonrise.circle
+moonrise.circle.fill
+moonrise.fill
+moonset
+moonset.circle
+moonset.circle.fill
+moonset.fill
+moped
+moped.fill
+mosaic
+mosaic.fill
+motorcycle
+motorcycle.fill
+mount
+mount.fill
+mountain.2
+mountain.2.circle
+mountain.2.circle.fill
+mountain.2.fill
+mouth
+mouth.fill
+move.3d
+movieclapper
+movieclapper.fill
+mph
+mph.circle
+mph.circle.fill
+mug
+mug.fill
+multiply
+multiply.circle
+multiply.circle.fill
+multiply.square
+multiply.square.fill
+music.house
+music.house.fill
+music.mic
+music.mic.circle
+music.mic.circle.fill
+music.microphone
+music.microphone.circle
+music.microphone.circle.fill
+music.note
+music.note.arrow.trianglehead.clockwise
+music.note.house
+music.note.house.fill
+music.note.list
+music.note.slash
+music.note.square.stack
+music.note.square.stack.fill
+music.note.tv
+music.note.tv.fill
+music.pages
+music.pages.fill
+music.quarternote.3
+mustache
+mustache.fill
+n.circle
+n.circle.fill
+n.square
+n.square.fill
+nairasign
+nairasign.arrow.circlepath
+nairasign.arrow.trianglehead.counterclockwise.rotate.90
+nairasign.bank.building
+nairasign.bank.building.fill
+nairasign.circle
+nairasign.circle.fill
+nairasign.gauge.chart.lefthalf.righthalf
+nairasign.gauge.chart.leftthird.topthird.rightthird
+nairasign.ring
+nairasign.ring.dashed
+nairasign.square
+nairasign.square.fill
+network
+network.badge.shield.half.filled
+network.slash
+newspaper
+newspaper.circle
+newspaper.circle.fill
+newspaper.fill
+norwegiankronesign
+norwegiankronesign.arrow.circlepath
+norwegiankronesign.arrow.trianglehead.counterclockwise.rotate.90
+norwegiankronesign.bank.building
+norwegiankronesign.bank.building.fill
+norwegiankronesign.circle
+norwegiankronesign.circle.fill
+norwegiankronesign.gauge.chart.lefthalf.righthalf
+norwegiankronesign.gauge.chart.leftthird.topthird.rightthird
+norwegiankronesign.ring
+norwegiankronesign.ring.dashed
+norwegiankronesign.square
+norwegiankronesign.square.fill
+nose
+nose.fill
+nosign
+nosign.app
+nosign.app.fill
+nosign.badge.clock
+note
+note.text
+note.text.badge.plus
+notequal
+notequal.circle
+notequal.circle.fill
+notequal.square
+notequal.square.fill
+number
+number.circle
+number.circle.fill
+number.square
+number.square.fill
+numbers
+numbers.bn
+numbers.gu
+numbers.km
+numbers.kn
+numbers.ml
+numbers.mni
+numbers.mr
+numbers.my
+numbers.or
+numbers.pa
+numbers.rectangle
+numbers.rectangle.fill
+numbers.sat
+numbers.te
+numbersign
+o.circle
+o.circle.fill
+o.square
+o.square.fill
+oar.2.crossed
+oar.2.crossed.circle
+oar.2.crossed.circle.fill
+octagon
+octagon.bottomhalf.filled
+octagon.fill
+octagon.lefthalf.filled
+octagon.righthalf.filled
+octagon.tophalf.filled
+oilcan
+oilcan.and.thermometer
+oilcan.and.thermometer.fill
+oilcan.fill
+opticaldisc
+opticaldisc.fill
+opticaldiscdrive
+opticaldiscdrive.fill
+opticid
+opticid.fill
+option
+oval
+oval.bottomhalf.filled
+oval.fill
+oval.inset.filled
+oval.lefthalf.filled
+oval.portrait
+oval.portrait.bottomhalf.filled
+oval.portrait.fill
+oval.portrait.inset.filled
+oval.portrait.lefthalf.filled
+oval.portrait.righthalf.filled
+oval.portrait.tophalf.filled
+oval.righthalf.filled
+oval.tophalf.filled
+oven
+oven.fill
+p.circle
+p.circle.fill
+p.square
+p.square.fill
+p1.button.horizontal
+p1.button.horizontal.fill
+p2.button.horizontal
+p2.button.horizontal.fill
+p3.button.horizontal
+p3.button.horizontal.fill
+p4.button.horizontal
+p4.button.horizontal.fill
+pad.header
+paddleshifter.left
+paddleshifter.left.fill
+paddleshifter.right
+paddleshifter.right.fill
+paint.bucket.classic
+paintbrush
+paintbrush.fill
+paintbrush.pointed
+paintbrush.pointed.fill
+paintpalette
+paintpalette.fill
+pano
+pano.badge.play
+pano.badge.play.fill
+pano.fill
+paperclip
+paperclip.badge.ellipsis
+paperclip.circle
+paperclip.circle.fill
+paperplane
+paperplane.circle
+paperplane.circle.fill
+paperplane.fill
+paragraph
+paragraphsign
+parentheses
+parkinglight
+parkinglight.fill
+parkingsign
+parkingsign.brakesignal
+parkingsign.brakesignal.slash
+parkingsign.circle
+parkingsign.circle.fill
+parkingsign.radiowaves.down.right.off
+parkingsign.radiowaves.left.and.right
+parkingsign.radiowaves.left.and.right.slash
+parkingsign.radiowaves.right.and.safetycone
+parkingsign.square
+parkingsign.square.fill
+parkingsign.steeringwheel
+party.popper
+party.popper.fill
+pause
+pause.circle
+pause.circle.fill
+pause.fill
+pause.rectangle
+pause.rectangle.fill
+pawprint
+pawprint.circle
+pawprint.circle.fill
+pawprint.fill
+pc
+peacesign
+pedal.accelerator
+pedal.accelerator.fill
+pedal.brake
+pedal.brake.fill
+pedal.clutch
+pedal.clutch.fill
+pedestrian.gate.closed
+pedestrian.gate.closed.trianglebadge.exclamationmark
+pedestrian.gate.open
+pedestrian.gate.open.trianglebadge.exclamationmark
+pencil
+pencil.and.ellipsis.rectangle
+pencil.and.list.clipboard
+pencil.and.outline
+pencil.and.ruler
+pencil.and.ruler.fill
+pencil.and.scribble
+pencil.circle
+pencil.circle.fill
+pencil.line
+pencil.slash
+pencil.tip
+pencil.tip.crop.circle
+pencil.tip.crop.circle.badge.arrow.forward
+pencil.tip.crop.circle.badge.arrow.forward.fill
+pencil.tip.crop.circle.badge.minus
+pencil.tip.crop.circle.badge.minus.fill
+pencil.tip.crop.circle.badge.plus
+pencil.tip.crop.circle.badge.plus.fill
+pencil.tip.crop.circle.fill
+pentagon
+pentagon.bottomhalf.filled
+pentagon.fill
+pentagon.lefthalf.filled
+pentagon.righthalf.filled
+pentagon.tophalf.filled
+percent
+person
+person.2
+person.2.arrow.trianglehead.counterclockwise
+person.2.badge
+person.2.badge.fill
+person.2.badge.gearshape
+person.2.badge.gearshape.fill
+person.2.badge.key
+person.2.badge.key.fill
+person.2.badge.minus
+person.2.badge.minus.fill
+person.2.badge.plus
+person.2.badge.plus.fill
+person.2.circle
+person.2.circle.fill
+person.2.crop.square.stack
+person.2.crop.square.stack.fill
+person.2.fill
+person.2.gobackward
+person.2.shield
+person.2.shield.fill
+person.2.slash
+person.2.slash.fill
+person.2.square.stack
+person.2.square.stack.fill
+person.2.wave.2
+person.2.wave.2.fill
+person.3
+person.3.fill
+person.3.sequence
+person.3.sequence.fill
+person.and.arrow.left.and.arrow.right
+person.and.arrow.left.and.arrow.right.outward
+person.and.background.dotted
+person.and.background.striped.horizontal
+person.badge.clock
+person.badge.clock.fill
+person.badge.key
+person.badge.key.fill
+person.badge.minus
+person.badge.minus.fill
+person.badge.plus
+person.badge.plus.fill
+person.badge.shield.checkmark
+person.badge.shield.checkmark.fill
+person.badge.shield.exclamationmark
+person.badge.shield.exclamationmark.fill
+person.bubble
+person.bubble.fill
+person.bust
+person.bust.circle
+person.bust.circle.fill
+person.bust.fill
+person.checkmark.and.xmark
+person.circle
+person.circle.fill
+person.crop.artframe
+person.crop.badge.magnifyingglass
+person.crop.badge.magnifyingglass.fill
+person.crop.circle
+person.crop.circle.badge
+person.crop.circle.badge.checkmark
+person.crop.circle.badge.clock
+person.crop.circle.badge.clock.fill
+person.crop.circle.badge.ellipsis
+person.crop.circle.badge.ellipsis.fill
+person.crop.circle.badge.exclam
+person.crop.circle.badge.exclamationmark
+person.crop.circle.badge.exclamationmark.fill
+person.crop.circle.badge.fill
+person.crop.circle.badge.minus
+person.crop.circle.badge.moon
+person.crop.circle.badge.moon.fill
+person.crop.circle.badge.plus
+person.crop.circle.badge.questionmark
+person.crop.circle.badge.questionmark.fill
+person.crop.circle.badge.xmark
+person.crop.circle.dashed
+person.crop.circle.dashed.circle
+person.crop.circle.dashed.circle.fill
+person.crop.circle.fill
+person.crop.circle.fill.badge.checkmark
+person.crop.circle.fill.badge.exclam
+person.crop.circle.fill.badge.exclamationmark
+person.crop.circle.fill.badge.minus
+person.crop.circle.fill.badge.plus
+person.crop.circle.fill.badge.questionmark
+person.crop.circle.fill.badge.xmark
+person.crop.rectangle
+person.crop.rectangle.badge.plus
+person.crop.rectangle.badge.plus.fill
+person.crop.rectangle.fill
+person.crop.rectangle.stack
+person.crop.rectangle.stack.fill
+person.crop.square
+person.crop.square.badge.camera
+person.crop.square.badge.camera.fill
+person.crop.square.badge.video
+person.crop.square.badge.video.fill
+person.crop.square.fill
+person.crop.square.fill.and.at.rectangle
+person.crop.square.filled.and.at.rectangle
+person.crop.square.filled.and.at.rectangle.fill
+person.crop.square.on.square.angled
+person.crop.square.on.square.angled.fill
+person.fill
+person.fill.and.arrow.left.and.arrow.right
+person.fill.and.arrow.left.and.arrow.right.outward
+person.fill.badge.minus
+person.fill.badge.plus
+person.fill.checkmark
+person.fill.checkmark.and.xmark
+person.fill.questionmark
+person.fill.turn.down
+person.fill.turn.left
+person.fill.turn.right
+person.fill.viewfinder
+person.fill.xmark
+person.icloud
+person.icloud.fill
+person.line.dotted.person
+person.line.dotted.person.fill
+person.slash
+person.slash.fill
+person.spatialaudio.3d.fill
+person.spatialaudio.fill
+person.spatialaudio.stereo.3d.fill
+person.spatialaudio.stereo.fill
+person.text.rectangle
+person.text.rectangle.fill
+person.text.rectangle.trianglebadge.exclamationmark
+person.text.rectangle.trianglebadge.exclamationmark.fill
+person.wave.2
+person.wave.2.fill
+personalhotspot
+personalhotspot.circle
+personalhotspot.circle.fill
+personalhotspot.slash
+perspective
+peruviansolessign
+peruviansolessign.arrow.trianglehead.counterclockwise.rotate.90
+peruviansolessign.bank.building
+peruviansolessign.bank.building.fill
+peruviansolessign.circle
+peruviansolessign.circle.fill
+peruviansolessign.gauge.chart.lefthalf.righthalf
+peruviansolessign.gauge.chart.leftthird.topthird.rightthird
+peruviansolessign.ring
+peruviansolessign.ring.dashed
+peruviansolessign.square
+peruviansolessign.square.fill
+pesetasign
+pesetasign.arrow.circlepath
+pesetasign.arrow.trianglehead.counterclockwise.rotate.90
+pesetasign.bank.building
+pesetasign.bank.building.fill
+pesetasign.circle
+pesetasign.circle.fill
+pesetasign.gauge.chart.lefthalf.righthalf
+pesetasign.gauge.chart.leftthird.topthird.rightthird
+pesetasign.ring
+pesetasign.ring.dashed
+pesetasign.square
+pesetasign.square.fill
+pesosign
+pesosign.arrow.circlepath
+pesosign.arrow.trianglehead.counterclockwise.rotate.90
+pesosign.bank.building
+pesosign.bank.building.fill
+pesosign.circle
+pesosign.circle.fill
+pesosign.gauge.chart.lefthalf.righthalf
+pesosign.gauge.chart.leftthird.topthird.rightthird
+pesosign.ring
+pesosign.ring.dashed
+pesosign.square
+pesosign.square.fill
+pet.carrier
+pet.carrier.circle
+pet.carrier.circle.fill
+pet.carrier.fill
+phone
+phone.and.waveform
+phone.and.waveform.fill
+phone.arrow.down.left
+phone.arrow.down.left.fill
+phone.arrow.right
+phone.arrow.right.fill
+phone.arrow.up.right
+phone.arrow.up.right.circle
+phone.arrow.up.right.circle.fill
+phone.arrow.up.right.fill
+phone.badge.checkmark
+phone.badge.clock
+phone.badge.clock.fill
+phone.badge.plus
+phone.badge.waveform
+phone.badge.waveform.fill
+phone.bubble
+phone.bubble.fill
+phone.bubble.left
+phone.bubble.left.fill
+phone.circle
+phone.circle.fill
+phone.connection
+phone.connection.fill
+phone.down
+phone.down.circle
+phone.down.circle.fill
+phone.down.fill
+phone.down.waves.left.and.right
+phone.fill
+phone.fill.arrow.down.left
+phone.fill.arrow.right
+phone.fill.arrow.up.right
+phone.fill.badge.checkmark
+phone.fill.badge.plus
+phone.fill.connection
+phone.pause
+phone.pause.circle
+phone.pause.circle.fill
+phone.pause.fill
+photo
+photo.artframe
+photo.artframe.circle
+photo.artframe.circle.fill
+photo.badge.arrow.down
+photo.badge.arrow.down.fill
+photo.badge.checkmark
+photo.badge.checkmark.fill
+photo.badge.exclamationmark
+photo.badge.exclamationmark.fill
+photo.badge.magnifyingglass
+photo.badge.magnifyingglass.fill
+photo.badge.plus
+photo.badge.plus.fill
+photo.badge.shield.exclamationmark
+photo.badge.shield.exclamationmark.fill
+photo.circle
+photo.circle.fill
+photo.fill
+photo.fill.on.rectangle.fill
+photo.on.rectangle
+photo.on.rectangle.angled
+photo.on.rectangle.angled.fill
+photo.stack
+photo.stack.fill
+photo.trianglebadge.exclamationmark
+photo.trianglebadge.exclamationmark.fill
+photo.tv
+pi
+pi.circle
+pi.circle.fill
+pi.square
+pi.square.fill
+pianokeys
+pianokeys.inverse
+pill
+pill.circle
+pill.circle.fill
+pill.fill
+pills
+pills.circle
+pills.circle.fill
+pills.fill
+pin
+pin.circle
+pin.circle.fill
+pin.fill
+pin.slash
+pin.slash.fill
+pin.square
+pin.square.fill
+pip
+pip.enter
+pip.exit
+pip.fill
+pip.remove
+pip.swap
+pipe.and.drop
+pipe.and.drop.fill
+placeholdertext.fill
+platter.2.filled.ipad
+platter.2.filled.ipad.landscape
+platter.2.filled.iphone
+platter.2.filled.iphone.landscape
+platter.bottom.applewatch.case
+platter.filled.bottom.and.arrow.down.iphone
+platter.filled.bottom.applewatch.case
+platter.filled.bottom.iphone
+platter.filled.top.and.arrow.up.iphone
+platter.filled.top.applewatch.case
+platter.filled.top.iphone
+platter.top.applewatch.case
+play
+play.circle
+play.circle.fill
+play.desktopcomputer
+play.diamond
+play.diamond.fill
+play.display
+play.fill
+play.house
+play.house.fill
+play.laptopcomputer
+play.rectangle
+play.rectangle.fill
+play.rectangle.on.rectangle
+play.rectangle.on.rectangle.circle
+play.rectangle.on.rectangle.circle.fill
+play.rectangle.on.rectangle.fill
+play.slash
+play.slash.fill
+play.square
+play.square.fill
+play.square.stack
+play.square.stack.fill
+play.tv
+play.tv.fill
+playpause
+playpause.circle
+playpause.circle.fill
+playpause.fill
+playstation.logo
+plus
+plus.app
+plus.app.fill
+plus.arrow.trianglehead.clockwise
+plus.arrow.trianglehead.counterclockwise
+plus.bubble
+plus.bubble.fill
+plus.capsule
+plus.capsule.fill
+plus.circle
+plus.circle.dashed
+plus.circle.fill
+plus.diamond
+plus.diamond.fill
+plus.forwardslash.minus
+plus.magnifyingglass
+plus.message
+plus.message.fill
+plus.minus.capsule
+plus.minus.capsule.fill
+plus.rectangle
+plus.rectangle.fill
+plus.rectangle.fill.on.folder.fill
+plus.rectangle.fill.on.rectangle.fill
+plus.rectangle.on.folder
+plus.rectangle.on.folder.fill
+plus.rectangle.on.rectangle
+plus.rectangle.portrait
+plus.rectangle.portrait.fill
+plus.slash.minus
+plus.square
+plus.square.dashed
+plus.square.fill
+plus.square.fill.on.square.fill
+plus.square.on.square
+plus.viewfinder
+plusminus
+plusminus.circle
+plusminus.circle.fill
+point.3.connected.trianglepath.dotted
+point.3.filled.connected.trianglepath.dotted
+point.bottomleft.filled.forward.to.point.topright.scurvepath
+point.bottomleft.forward.to.arrow.triangle.scurvepath
+point.bottomleft.forward.to.arrow.triangle.scurvepath.fill
+point.bottomleft.forward.to.arrow.triangle.uturn.scurvepath
+point.bottomleft.forward.to.arrow.triangle.uturn.scurvepath.fill
+point.bottomleft.forward.to.arrowtriangle.uturn.scurvepath
+point.bottomleft.forward.to.arrowtriangle.uturn.scurvepath.fill
+point.bottomleft.forward.to.point.topright.filled.scurvepath
+point.bottomleft.forward.to.point.topright.scurvepath
+point.bottomleft.forward.to.point.topright.scurvepath.fill
+point.fill.topleft.down.curvedto.point.fill.bottomright.up
+point.filled.topleft.down.curvedto.point.bottomright.up
+point.forward.to.point.capsulepath
+point.forward.to.point.capsulepath.fill
+point.topleft.down.curvedto.point.bottomright.up
+point.topleft.down.curvedto.point.bottomright.up.fill
+point.topleft.down.curvedto.point.filled.bottomright.up
+point.topleft.down.to.point.bottomright.curvepath
+point.topleft.down.to.point.bottomright.curvepath.fill
+point.topleft.down.to.point.bottomright.filled.curvepath
+point.topleft.filled.down.to.point.bottomright.curvepath
+point.topright.arrow.triangle.backward.to.point.bottomleft.filled.scurvepath
+point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath
+point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill
+point.topright.filled.arrow.triangle.backward.to.point.bottomleft.scurvepath
+pointer.arrow
+pointer.arrow.and.square.on.square.dashed
+pointer.arrow.click
+pointer.arrow.click.2
+pointer.arrow.click.badge.clock
+pointer.arrow.ipad
+pointer.arrow.ipad.and.square.on.square.dashed
+pointer.arrow.ipad.rays
+pointer.arrow.ipad.slash
+pointer.arrow.ipad.slash.square
+pointer.arrow.ipad.slash.square.fill
+pointer.arrow.ipad.square
+pointer.arrow.ipad.square.fill
+pointer.arrow.motionlines
+pointer.arrow.motionlines.click
+pointer.arrow.rays
+pointer.arrow.slash
+pointer.arrow.slash.square
+pointer.arrow.slash.square.fill
+pointer.arrow.square
+pointer.arrow.square.fill
+polishzlotysign
+polishzlotysign.arrow.circlepath
+polishzlotysign.arrow.trianglehead.counterclockwise.rotate.90
+polishzlotysign.bank.building
+polishzlotysign.bank.building.fill
+polishzlotysign.circle
+polishzlotysign.circle.fill
+polishzlotysign.gauge.chart.lefthalf.righthalf
+polishzlotysign.gauge.chart.leftthird.topthird.rightthird
+polishzlotysign.ring
+polishzlotysign.ring.dashed
+polishzlotysign.square
+polishzlotysign.square.fill
+popcorn
+popcorn.circle
+popcorn.circle.fill
+popcorn.fill
+power
+power.circle
+power.circle.fill
+power.dotted
+powercord
+powercord.fill
+powermeter
+poweroff
+poweron
+poweroutlet.strip
+poweroutlet.strip.fill
+poweroutlet.type.a
+poweroutlet.type.a.fill
+poweroutlet.type.a.square
+poweroutlet.type.a.square.fill
+poweroutlet.type.b
+poweroutlet.type.b.fill
+poweroutlet.type.b.square
+poweroutlet.type.b.square.fill
+poweroutlet.type.c
+poweroutlet.type.c.fill
+poweroutlet.type.c.square
+poweroutlet.type.c.square.fill
+poweroutlet.type.d
+poweroutlet.type.d.fill
+poweroutlet.type.d.square
+poweroutlet.type.d.square.fill
+poweroutlet.type.e
+poweroutlet.type.e.fill
+poweroutlet.type.e.square
+poweroutlet.type.e.square.fill
+poweroutlet.type.f
+poweroutlet.type.f.fill
+poweroutlet.type.f.square
+poweroutlet.type.f.square.fill
+poweroutlet.type.g
+poweroutlet.type.g.fill
+poweroutlet.type.g.square
+poweroutlet.type.g.square.fill
+poweroutlet.type.h
+poweroutlet.type.h.fill
+poweroutlet.type.h.square
+poweroutlet.type.h.square.fill
+poweroutlet.type.i
+poweroutlet.type.i.fill
+poweroutlet.type.i.square
+poweroutlet.type.i.square.fill
+poweroutlet.type.j
+poweroutlet.type.j.fill
+poweroutlet.type.j.square
+poweroutlet.type.j.square.fill
+poweroutlet.type.k
+poweroutlet.type.k.fill
+poweroutlet.type.k.square
+poweroutlet.type.k.square.fill
+poweroutlet.type.l
+poweroutlet.type.l.fill
+poweroutlet.type.l.square
+poweroutlet.type.l.square.fill
+poweroutlet.type.m
+poweroutlet.type.m.fill
+poweroutlet.type.m.square
+poweroutlet.type.m.square.fill
+poweroutlet.type.n
+poweroutlet.type.n.fill
+poweroutlet.type.n.square
+poweroutlet.type.n.square.fill
+poweroutlet.type.o
+poweroutlet.type.o.fill
+poweroutlet.type.o.square
+poweroutlet.type.o.square.fill
+powerplug
+powerplug.fill
+powerplug.portrait
+powerplug.portrait.fill
+powersleep
+printer
+printer.dotmatrix
+printer.dotmatrix.fill
+printer.dotmatrix.fill.and.paper.fill
+printer.dotmatrix.filled.and.paper
+printer.dotmatrix.filled.and.paper.inverse
+printer.dotmatrix.inverse
+printer.fill
+printer.fill.and.paper.fill
+printer.filled.and.paper
+printer.filled.and.paper.inverse
+printer.inverse
+progress.indicator
+projective
+purchased
+purchased.circle
+purchased.circle.fill
+puzzlepiece
+puzzlepiece.extension
+puzzlepiece.extension.fill
+puzzlepiece.fill
+pyramid
+pyramid.fill
+q.circle
+q.circle.fill
+q.square
+q.square.fill
+qrcode
+qrcode.viewfinder
+questionmark
+questionmark.app
+questionmark.app.dashed
+questionmark.app.fill
+questionmark.bubble
+questionmark.bubble.fill
+questionmark.circle
+questionmark.circle.dashed
+questionmark.circle.fill
+questionmark.diamond
+questionmark.diamond.fill
+questionmark.folder
+questionmark.folder.fill
+questionmark.key.filled
+questionmark.message
+questionmark.message.fill
+questionmark.square
+questionmark.square.dashed
+questionmark.square.fill
+questionmark.text.page
+questionmark.text.page.fill
+questionmark.video
+questionmark.video.fill
+quote.bubble
+quote.bubble.fill
+quote.closing
+quote.opening
+quotelevel
+r.button.roundedbottom.horizontal
+r.button.roundedbottom.horizontal.fill
+r.circle
+r.circle.fill
+r.joystick
+r.joystick.down
+r.joystick.down.fill
+r.joystick.fill
+r.joystick.press.down
+r.joystick.press.down.fill
+r.joystick.tilt.down
+r.joystick.tilt.down.fill
+r.joystick.tilt.left
+r.joystick.tilt.left.fill
+r.joystick.tilt.right
+r.joystick.tilt.right.fill
+r.joystick.tilt.up
+r.joystick.tilt.up.fill
+r.rectangle.roundedbottom
+r.rectangle.roundedbottom.fill
+r.square
+r.square.fill
+r.square.fill.on.square.fill
+r.square.on.square
+r.square.on.square.fill
+r1.button.roundedbottom.horizontal
+r1.button.roundedbottom.horizontal.fill
+r1.circle
+r1.circle.fill
+r1.rectangle.roundedbottom
+r1.rectangle.roundedbottom.fill
+r2.button.angledtop.vertical.right
+r2.button.angledtop.vertical.right.fill
+r2.button.roundedtop.horizontal
+r2.button.roundedtop.horizontal.fill
+r2.circle
+r2.circle.fill
+r2.rectangle.roundedtop
+r2.rectangle.roundedtop.fill
+r3.button.angledbottom.horizontal.right
+r3.button.angledbottom.horizontal.right.fill
+r4.button.horizontal
+r4.button.horizontal.fill
+radicand.squareroot
+radio
+radio.fill
+radiowaves.left
+radiowaves.right
+rainbow
+rays
+rb.button.roundedbottom.horizontal
+rb.button.roundedbottom.horizontal.fill
+rb.circle
+rb.circle.fill
+rb.rectangle.roundedbottom
+rb.rectangle.roundedbottom.fill
+receipt
+receipt.fill
+record.circle
+record.circle.fill
+recordingtape
+recordingtape.circle
+recordingtape.circle.fill
+rectangle
+rectangle.2.swap
+rectangle.3.group
+rectangle.3.group.bubble
+rectangle.3.group.bubble.fill
+rectangle.3.group.bubble.left
+rectangle.3.group.bubble.left.fill
+rectangle.3.group.dashed
+rectangle.3.group.fill
+rectangle.3.offgrid
+rectangle.3.offgrid.bubble.left
+rectangle.3.offgrid.bubble.left.fill
+rectangle.3.offgrid.fill
+rectangle.and.arrow.up.right.and.arrow.down.left
+rectangle.and.arrow.up.right.and.arrow.down.left.slash
+rectangle.and.hand.point.up.left
+rectangle.and.hand.point.up.left.fill
+rectangle.and.hand.point.up.left.filled
+rectangle.and.paperclip
+rectangle.and.pencil.and.ellipsis
+rectangle.and.text.magnifyingglass
+rectangle.arrowtriangle.2.inward
+rectangle.arrowtriangle.2.outward
+rectangle.badge.checkmark
+rectangle.badge.minus
+rectangle.badge.person.crop
+rectangle.badge.plus
+rectangle.badge.sparkles
+rectangle.badge.sparkles.fill
+rectangle.badge.xmark
+rectangle.bottomhalf.filled
+rectangle.bottomhalf.inset.filled
+rectangle.bottomthird.inset.fill
+rectangle.bottomthird.inset.filled
+rectangle.center.inset.fill
+rectangle.center.inset.filled
+rectangle.center.inset.filled.badge.plus
+rectangle.checkered
+rectangle.compress.vertical
+rectangle.connected.to.line.below
+rectangle.dashed
+rectangle.dashed.and.paperclip
+rectangle.dashed.badge.record
+rectangle.dock
+rectangle.expand.diagonal
+rectangle.expand.vertical
+rectangle.fill
+rectangle.fill.badge.checkmark
+rectangle.fill.badge.minus
+rectangle.fill.badge.person.crop
+rectangle.fill.badge.plus
+rectangle.fill.badge.xmark
+rectangle.fill.on.rectangle.angled.fill
+rectangle.fill.on.rectangle.fill
+rectangle.fill.on.rectangle.fill.circle
+rectangle.fill.on.rectangle.fill.circle.fill
+rectangle.fill.on.rectangle.fill.slash.fill
+rectangle.filled.and.hand.point.up.left
+rectangle.grid.1x2
+rectangle.grid.1x2.fill
+rectangle.grid.1x3
+rectangle.grid.1x3.fill
+rectangle.grid.2x2
+rectangle.grid.2x2.fill
+rectangle.grid.3x1
+rectangle.grid.3x1.fill
+rectangle.grid.3x2
+rectangle.grid.3x2.fill
+rectangle.grid.3x3
+rectangle.grid.3x3.fill
+rectangle.inset.bottomleading.filled
+rectangle.inset.bottomleft.fill
+rectangle.inset.bottomleft.filled
+rectangle.inset.bottomright.fill
+rectangle.inset.bottomright.filled
+rectangle.inset.bottomtrailing.filled
+rectangle.inset.fill
+rectangle.inset.filled
+rectangle.inset.filled.and.cursorarrow
+rectangle.inset.filled.and.person.filled
+rectangle.inset.filled.badge.record
+rectangle.inset.filled.on.rectangle
+rectangle.inset.topleading.filled
+rectangle.inset.topleft.fill
+rectangle.inset.topleft.filled
+rectangle.inset.topright.fill
+rectangle.inset.topright.filled
+rectangle.inset.toptrailing.filled
+rectangle.landscape.rotate
+rectangle.landscape.rotate.slash
+rectangle.leadinghalf.filled
+rectangle.leadinghalf.inset.filled
+rectangle.leadinghalf.inset.filled.arrow.leading
+rectangle.leadingthird.inset.filled
+rectangle.lefthalf.fill
+rectangle.lefthalf.filled
+rectangle.lefthalf.inset.fill
+rectangle.lefthalf.inset.fill.arrow.left
+rectangle.lefthalf.inset.filled
+rectangle.lefthalf.inset.filled.arrow.left
+rectangle.leftthird.inset.fill
+rectangle.leftthird.inset.filled
+rectangle.on.rectangle
+rectangle.on.rectangle.angled
+rectangle.on.rectangle.badge.gearshape
+rectangle.on.rectangle.button.angledtop.vertical.left
+rectangle.on.rectangle.button.angledtop.vertical.left.fill
+rectangle.on.rectangle.circle
+rectangle.on.rectangle.circle.fill
+rectangle.on.rectangle.dashed
+rectangle.on.rectangle.slash
+rectangle.on.rectangle.slash.circle
+rectangle.on.rectangle.slash.circle.fill
+rectangle.on.rectangle.slash.fill
+rectangle.on.rectangle.square
+rectangle.on.rectangle.square.fill
+rectangle.pattern.checkered
+rectangle.portrait
+rectangle.portrait.and.arrow.forward
+rectangle.portrait.and.arrow.forward.fill
+rectangle.portrait.and.arrow.right
+rectangle.portrait.and.arrow.right.fill
+rectangle.portrait.arrowtriangle.2.inward
+rectangle.portrait.arrowtriangle.2.outward
+rectangle.portrait.badge.plus
+rectangle.portrait.badge.plus.fill
+rectangle.portrait.bottomhalf.filled
+rectangle.portrait.bottomhalf.inset.filled
+rectangle.portrait.bottomleading.inset.filled
+rectangle.portrait.bottomleft.inset.filled
+rectangle.portrait.bottomright.inset.filled
+rectangle.portrait.bottomthird.inset.filled
+rectangle.portrait.bottomtrailing.inset.filled
+rectangle.portrait.center.inset.filled
+rectangle.portrait.fill
+rectangle.portrait.inset.filled
+rectangle.portrait.leadinghalf.inset.filled
+rectangle.portrait.leadingthird.inset.filled
+rectangle.portrait.lefthalf.filled
+rectangle.portrait.lefthalf.inset.filled
+rectangle.portrait.leftthird.inset.filled
+rectangle.portrait.on.rectangle.portrait
+rectangle.portrait.on.rectangle.portrait.angled
+rectangle.portrait.on.rectangle.portrait.angled.fill
+rectangle.portrait.on.rectangle.portrait.fill
+rectangle.portrait.on.rectangle.portrait.slash
+rectangle.portrait.on.rectangle.portrait.slash.fill
+rectangle.portrait.righthalf.filled
+rectangle.portrait.righthalf.inset.filled
+rectangle.portrait.rightthird.inset.filled
+rectangle.portrait.rotate
+rectangle.portrait.rotate.slash
+rectangle.portrait.slash
+rectangle.portrait.slash.fill
+rectangle.portrait.split.2x1
+rectangle.portrait.split.2x1.fill
+rectangle.portrait.split.2x1.slash
+rectangle.portrait.split.2x1.slash.fill
+rectangle.portrait.tophalf.filled
+rectangle.portrait.tophalf.inset.filled
+rectangle.portrait.topleading.inset.filled
+rectangle.portrait.topleft.inset.filled
+rectangle.portrait.topright.inset.filled
+rectangle.portrait.topthird.inset.filled
+rectangle.portrait.toptrailing.inset.filled
+rectangle.portrait.trailinghalf.inset.filled
+rectangle.portrait.trailingthird.inset.filled
+rectangle.ratio.16.to.9
+rectangle.ratio.16.to.9.fill
+rectangle.ratio.3.to.4
+rectangle.ratio.3.to.4.fill
+rectangle.ratio.4.to.3
+rectangle.ratio.4.to.3.fill
+rectangle.ratio.9.to.16
+rectangle.ratio.9.to.16.fill
+rectangle.righthalf.fill
+rectangle.righthalf.filled
+rectangle.righthalf.inset.fill
+rectangle.righthalf.inset.fill.arrow.right
+rectangle.righthalf.inset.filled
+rectangle.righthalf.inset.filled.arrow.right
+rectangle.rightthird.inset.fill
+rectangle.rightthird.inset.filled
+rectangle.roundedbottom
+rectangle.roundedbottom.fill
+rectangle.roundedtop
+rectangle.roundedtop.fill
+rectangle.slash
+rectangle.slash.fill
+rectangle.split.1x2
+rectangle.split.1x2.fill
+rectangle.split.2x1
+rectangle.split.2x1.fill
+rectangle.split.2x1.slash
+rectangle.split.2x1.slash.fill
+rectangle.split.2x2
+rectangle.split.2x2.fill
+rectangle.split.3x1
+rectangle.split.3x1.fill
+rectangle.split.3x3
+rectangle.split.3x3.fill
+rectangle.stack
+rectangle.stack.badge.minus
+rectangle.stack.badge.person.crop
+rectangle.stack.badge.person.crop.fill
+rectangle.stack.badge.play
+rectangle.stack.badge.play.fill
+rectangle.stack.badge.plus
+rectangle.stack.fill
+rectangle.stack.fill.badge.minus
+rectangle.stack.fill.badge.person.crop
+rectangle.stack.fill.badge.plus
+rectangle.stack.person.crop
+rectangle.stack.person.crop.fill
+rectangle.stack.slash
+rectangle.stack.slash.fill
+rectangle.tophalf.filled
+rectangle.tophalf.inset.filled
+rectangle.topthird.inset
+rectangle.topthird.inset.fill
+rectangle.topthird.inset.filled
+rectangle.trailinghalf.filled
+rectangle.trailinghalf.inset.filled
+rectangle.trailinghalf.inset.filled.arrow.trailing
+rectangle.trailingthird.inset.filled
+refrigerator
+refrigerator.fill
+repeat
+repeat.1
+repeat.1.circle
+repeat.1.circle.fill
+repeat.badge.xmark
+repeat.badge.xmark.circle.fill
+repeat.circle
+repeat.circle.fill
+restart
+restart.circle
+restart.circle.fill
+retarder.brakesignal
+retarder.brakesignal.and.exclamationmark
+retarder.brakesignal.slash
+return
+return.left
+return.right
+rhombus
+rhombus.fill
+richtext.page
+richtext.page.fill
+right
+right.circle
+right.circle.fill
+righttriangle
+righttriangle.fill
+righttriangle.split.diagonal
+righttriangle.split.diagonal.fill
+ring
+ring.dashed
+rm.button.horizontal
+rm.button.horizontal.fill
+road.lane.arrowtriangle.2.inward
+road.lane.arrowtriangle.2.outward
+road.lanes
+road.lanes.curved.left
+road.lanes.curved.right
+robotic.vacuum
+robotic.vacuum.and.arrowtriangle.up
+robotic.vacuum.and.arrowtriangle.up.fill
+robotic.vacuum.and.ellipsis
+robotic.vacuum.and.ellipsis.fill
+robotic.vacuum.fill
+roller.shade.closed
+roller.shade.open
+roman.shade.closed
+roman.shade.open
+rosette
+rotate.3d
+rotate.3d.circle
+rotate.3d.circle.fill
+rotate.3d.fill
+rotate.left
+rotate.left.fill
+rotate.right
+rotate.right.fill
+rsb.button.angledbottom.horizontal.right
+rsb.button.angledbottom.horizontal.right.fill
+rt.button.roundedtop.horizontal
+rt.button.roundedtop.horizontal.fill
+rt.circle
+rt.circle.fill
+rt.rectangle.roundedtop
+rt.rectangle.roundedtop.fill
+rublesign
+rublesign.arrow.circlepath
+rublesign.arrow.trianglehead.counterclockwise.rotate.90
+rublesign.bank.building
+rublesign.bank.building.fill
+rublesign.circle
+rublesign.circle.fill
+rublesign.gauge.chart.lefthalf.righthalf
+rublesign.gauge.chart.leftthird.topthird.rightthird
+rublesign.ring
+rublesign.ring.dashed
+rublesign.square
+rublesign.square.fill
+rugbyball
+rugbyball.circle
+rugbyball.circle.fill
+rugbyball.fill
+ruler
+ruler.fill
+rupeesign
+rupeesign.arrow.circlepath
+rupeesign.arrow.trianglehead.counterclockwise.rotate.90
+rupeesign.bank.building
+rupeesign.bank.building.fill
+rupeesign.circle
+rupeesign.circle.fill
+rupeesign.gauge.chart.lefthalf.righthalf
+rupeesign.gauge.chart.leftthird.topthird.rightthird
+rupeesign.ring
+rupeesign.ring.dashed
+rupeesign.square
+rupeesign.square.fill
+s.circle
+s.circle.fill
+s.square
+s.square.fill
+safari
+safari.fill
+sailboat
+sailboat.circle
+sailboat.circle.fill
+sailboat.fill
+scale.3d
+scalemass
+scalemass.fill
+scanner
+scanner.fill
+scissors
+scissors.badge.ellipsis
+scissors.circle
+scissors.circle.fill
+scooter
+scope
+screwdriver
+screwdriver.fill
+scribble
+scribble.variable
+scroll
+scroll.fill
+sdcard
+sdcard.fill
+seal
+seal.fill
+selection.pin.in.out
+sensor
+sensor.fill
+sensor.radiowaves.left.and.right
+sensor.radiowaves.left.and.right.fill
+sensor.tag.radiowaves.forward
+sensor.tag.radiowaves.forward.fill
+server.rack
+service.dog
+service.dog.fill
+shadow
+shared.with.you
+shared.with.you.circle
+shared.with.you.circle.fill
+shared.with.you.slash
+sharedwithyou
+sharedwithyou.circle
+sharedwithyou.circle.fill
+sharedwithyou.slash
+shareplay
+shareplay.slash
+shazam.logo
+shazam.logo.fill
+shekelsign
+shekelsign.arrow.circlepath
+shekelsign.arrow.trianglehead.counterclockwise.rotate.90
+shekelsign.bank.building
+shekelsign.bank.building.fill
+shekelsign.circle
+shekelsign.circle.fill
+shekelsign.gauge.chart.lefthalf.righthalf
+shekelsign.gauge.chart.leftthird.topthird.rightthird
+shekelsign.ring
+shekelsign.ring.dashed
+shekelsign.square
+shekelsign.square.fill
+sheqelsign.circle
+sheqelsign.circle.fill
+sheqelsign.square
+sheqelsign.square.fill
+shield
+shield.checkerboard
+shield.checkered
+shield.fill
+shield.lefthalf.fill
+shield.lefthalf.fill.slash
+shield.lefthalf.filled
+shield.lefthalf.filled.badge.checkmark
+shield.lefthalf.filled.slash
+shield.lefthalf.filled.trianglebadge.exclamationmark
+shield.pattern.checkered
+shield.righthalf.filled
+shield.slash
+shield.slash.fill
+shift
+shift.fill
+shippingbox
+shippingbox.and.arrow.backward
+shippingbox.and.arrow.backward.fill
+shippingbox.circle
+shippingbox.circle.fill
+shippingbox.fill
+shoe
+shoe.2
+shoe.2.fill
+shoe.arrow.trianglehead.up.and.down
+shoe.arrow.trianglehead.up.and.down.fill
+shoe.arrow.trianglehead.up.right
+shoe.arrow.trianglehead.up.right.circle
+shoe.arrow.trianglehead.up.right.circle.fill
+shoe.arrow.trianglehead.up.right.fill
+shoe.circle
+shoe.circle.fill
+shoe.fill
+shoeprints.fill
+shower
+shower.fill
+shower.handheld
+shower.handheld.fill
+shower.sidejet
+shower.sidejet.fill
+shuffle
+shuffle.circle
+shuffle.circle.fill
+sidebar.leading
+sidebar.left
+sidebar.right
+sidebar.squares.leading
+sidebar.squares.left
+sidebar.squares.right
+sidebar.squares.trailing
+sidebar.trailing
+signature
+signpost.and.arrowtriangle.up
+signpost.and.arrowtriangle.up.circle
+signpost.and.arrowtriangle.up.circle.fill
+signpost.and.arrowtriangle.up.fill
+signpost.left
+signpost.left.circle
+signpost.left.circle.fill
+signpost.left.fill
+signpost.right
+signpost.right.and.left
+signpost.right.and.left.circle
+signpost.right.and.left.circle.fill
+signpost.right.and.left.fill
+signpost.right.circle
+signpost.right.circle.fill
+signpost.right.fill
+simcard
+simcard.2
+simcard.2.fill
+simcard.fill
+singaporedollarsign
+singaporedollarsign.arrow.trianglehead.counterclockwise.rotate.90
+singaporedollarsign.bank.building
+singaporedollarsign.bank.building.fill
+singaporedollarsign.circle
+singaporedollarsign.circle.fill
+singaporedollarsign.gauge.chart.lefthalf.righthalf
+singaporedollarsign.gauge.chart.leftthird.topthird.rightthird
+singaporedollarsign.ring
+singaporedollarsign.ring.dashed
+singaporedollarsign.square
+singaporedollarsign.square.fill
+sink
+sink.fill
+siri
+skateboard
+skateboard.fill
+skew
+skis
+skis.fill
+slash.circle
+slash.circle.fill
+sleep
+sleep.circle
+sleep.circle.fill
+slider.horizontal.2.arrow.trianglehead.counterclockwise
+slider.horizontal.2.gobackward
+slider.horizontal.2.rectangle.and.arrow.triangle.2.circlepath
+slider.horizontal.2.rectangle.and.arrow.trianglehead.2.clockwise.rotate.90
+slider.horizontal.2.square
+slider.horizontal.2.square.badge.arrow.down
+slider.horizontal.2.square.on.square
+slider.horizontal.3
+slider.horizontal.below.circle.lefthalf.filled
+slider.horizontal.below.circle.lefthalf.filled.inverse
+slider.horizontal.below.circle.righthalf.filled
+slider.horizontal.below.circle.righthalf.filled.inverse
+slider.horizontal.below.rectangle
+slider.horizontal.below.square.and.square.filled
+slider.horizontal.below.square.fill.and.square
+slider.horizontal.below.square.filled.and.square
+slider.horizontal.below.sun.max
+slider.horizontal.below.sun.min
+slider.vertical.3
+slowmo
+smallcircle.circle
+smallcircle.circle.fill
+smallcircle.fill.circle
+smallcircle.fill.circle.fill
+smallcircle.filled.circle
+smallcircle.filled.circle.fill
+smartphone
+smiley
+smiley.fill
+smoke
+smoke.circle
+smoke.circle.fill
+smoke.fill
+snow
+snowboard
+snowboard.fill
+snowflake
+snowflake.circle
+snowflake.circle.fill
+snowflake.road.lane
+snowflake.road.lane.dashed
+snowflake.slash
+soccerball
+soccerball.circle
+soccerball.circle.fill
+soccerball.circle.fill.inverse
+soccerball.circle.inverse
+soccerball.inverse
+sofa
+sofa.fill
+sos
+sos.circle
+sos.circle.fill
+space
+sparkle
+sparkle.magnifyingglass
+sparkle.text.clipboard
+sparkle.text.clipboard.fill
+sparkles
+sparkles.2
+sparkles.rectangle.stack
+sparkles.rectangle.stack.fill
+sparkles.square.fill.on.square
+sparkles.square.filled.on.square
+sparkles.tv
+sparkles.tv.fill
+spatial.capture
+spatial.capture.fill
+spatial.capture.on.hexagon
+spatial.capture.on.hexagon.fill
+spatial.capture.slash
+spatial.capture.slash.fill
+speaker
+speaker.1
+speaker.1.fill
+speaker.2
+speaker.2.fill
+speaker.3
+speaker.3.fill
+speaker.badge.exclamationmark
+speaker.badge.exclamationmark.fill
+speaker.circle
+speaker.circle.fill
+speaker.fill
+speaker.minus
+speaker.minus.fill
+speaker.plus
+speaker.plus.fill
+speaker.slash
+speaker.slash.circle
+speaker.slash.circle.fill
+speaker.slash.fill
+speaker.square
+speaker.square.fill
+speaker.trianglebadge.exclamationmark
+speaker.trianglebadge.exclamationmark.fill
+speaker.wave.1
+speaker.wave.1.arrowtriangles.up.right.down.left
+speaker.wave.1.fill
+speaker.wave.2
+speaker.wave.2.bubble
+speaker.wave.2.bubble.fill
+speaker.wave.2.bubble.left
+speaker.wave.2.bubble.left.fill
+speaker.wave.2.circle
+speaker.wave.2.circle.fill
+speaker.wave.2.fill
+speaker.wave.3
+speaker.wave.3.fill
+speaker.zzz
+speaker.zzz.fill
+speedometer
+spigot
+spigot.fill
+spoon.serving
+sportscourt
+sportscourt.circle
+sportscourt.circle.fill
+sportscourt.fill
+sprinkler
+sprinkler.and.droplets
+sprinkler.and.droplets.fill
+sprinkler.fill
+square
+square.2.layers.3d
+square.2.layers.3d.bottom.filled
+square.2.layers.3d.fill
+square.2.layers.3d.top.filled
+square.2.stack.3d
+square.2.stack.3d.bottom.fill
+square.2.stack.3d.bottom.filled
+square.2.stack.3d.top.fill
+square.2.stack.3d.top.filled
+square.3.layers.3d
+square.3.layers.3d.bottom.filled
+square.3.layers.3d.down.backward
+square.3.layers.3d.down.forward
+square.3.layers.3d.down.left
+square.3.layers.3d.down.left.slash
+square.3.layers.3d.down.right
+square.3.layers.3d.down.right.slash
+square.3.layers.3d.middle.filled
+square.3.layers.3d.slash
+square.3.layers.3d.top.filled
+square.3.stack.3d
+square.3.stack.3d.bottom.fill
+square.3.stack.3d.bottom.filled
+square.3.stack.3d.middle.fill
+square.3.stack.3d.middle.filled
+square.3.stack.3d.slash
+square.3.stack.3d.top.fill
+square.3.stack.3d.top.filled
+square.and.arrow.down
+square.and.arrow.down.badge.checkmark
+square.and.arrow.down.badge.checkmark.fill
+square.and.arrow.down.badge.clock
+square.and.arrow.down.badge.clock.fill
+square.and.arrow.down.badge.xmark
+square.and.arrow.down.badge.xmark.fill
+square.and.arrow.down.fill
+square.and.arrow.down.on.square
+square.and.arrow.down.on.square.fill
+square.and.arrow.up
+square.and.arrow.up.badge.checkmark
+square.and.arrow.up.badge.checkmark.fill
+square.and.arrow.up.badge.clock
+square.and.arrow.up.badge.clock.fill
+square.and.arrow.up.circle
+square.and.arrow.up.circle.fill
+square.and.arrow.up.fill
+square.and.arrow.up.on.square
+square.and.arrow.up.on.square.fill
+square.and.arrow.up.trianglebadge.exclamationmark
+square.and.arrow.up.trianglebadge.exclamationmark.fill
+square.and.at.rectangle
+square.and.at.rectangle.fill
+square.and.line.vertical.and.square
+square.and.line.vertical.and.square.fill
+square.and.line.vertical.and.square.filled
+square.and.pencil
+square.and.pencil.circle
+square.and.pencil.circle.fill
+square.arrowtriangle.4.outward
+square.badge.plus
+square.badge.plus.fill
+square.bottomhalf.fill
+square.bottomhalf.filled
+square.bottomthird.inset.filled
+square.circle
+square.circle.fill
+square.dashed
+square.dashed.inset.fill
+square.dashed.inset.filled
+square.dotted
+square.fill
+square.fill.and.line.vertical.and.square
+square.fill.and.line.vertical.and.square.fill
+square.fill.and.line.vertical.square.fill
+square.fill.on.circle.fill
+square.fill.on.square
+square.fill.on.square.fill
+square.fill.text.grid.1x2
+square.filled.and.line.vertical.and.square
+square.filled.on.square
+square.grid.2x2
+square.grid.2x2.fill
+square.grid.3x1.below.line.grid.1x2
+square.grid.3x1.below.line.grid.1x2.fill
+square.grid.3x1.fill.below.line.grid.1x2
+square.grid.3x1.folder.badge.plus
+square.grid.3x1.folder.fill.badge.plus
+square.grid.3x2
+square.grid.3x2.fill
+square.grid.3x3
+square.grid.3x3.bottomleft.fill
+square.grid.3x3.bottomleft.filled
+square.grid.3x3.bottommiddle.fill
+square.grid.3x3.bottommiddle.filled
+square.grid.3x3.bottomright.fill
+square.grid.3x3.bottomright.filled
+square.grid.3x3.fill
+square.grid.3x3.fill.square
+square.grid.3x3.middle.fill
+square.grid.3x3.middle.filled
+square.grid.3x3.middleleft.fill
+square.grid.3x3.middleleft.filled
+square.grid.3x3.middleright.fill
+square.grid.3x3.middleright.filled
+square.grid.3x3.square
+square.grid.3x3.square.badge.ellipsis
+square.grid.3x3.topleft.fill
+square.grid.3x3.topleft.filled
+square.grid.3x3.topmiddle.fill
+square.grid.3x3.topmiddle.filled
+square.grid.3x3.topright.fill
+square.grid.3x3.topright.filled
+square.grid.4x3.fill
+square.inset.filled
+square.leadingthird.inset.filled
+square.lefthalf.fill
+square.lefthalf.filled
+square.leftthird.inset.filled
+square.on.circle
+square.on.square
+square.on.square.badge.person.crop
+square.on.square.badge.person.crop.fill
+square.on.square.dashed
+square.on.square.intersection.dashed
+square.on.square.squareshape.controlhandles
+square.resize
+square.resize.down
+square.resize.up
+square.righthalf.fill
+square.righthalf.filled
+square.rightthird.inset.filled
+square.slash
+square.slash.fill
+square.split.1x2
+square.split.1x2.fill
+square.split.2x1
+square.split.2x1.fill
+square.split.2x2
+square.split.2x2.fill
+square.split.bottomrightquarter
+square.split.bottomrightquarter.fill
+square.split.diagonal
+square.split.diagonal.2x2
+square.split.diagonal.2x2.fill
+square.split.diagonal.fill
+square.stack
+square.stack.3d.down.dottedline
+square.stack.3d.down.forward
+square.stack.3d.down.forward.fill
+square.stack.3d.down.right
+square.stack.3d.down.right.fill
+square.stack.3d.forward.dottedline
+square.stack.3d.forward.dottedline.fill
+square.stack.3d.up
+square.stack.3d.up.badge.a
+square.stack.3d.up.badge.a.fill
+square.stack.3d.up.badge.automatic
+square.stack.3d.up.badge.automatic.fill
+square.stack.3d.up.fill
+square.stack.3d.up.slash
+square.stack.3d.up.slash.fill
+square.stack.3d.up.trianglebadge.exclamationmark
+square.stack.3d.up.trianglebadge.exclamationmark.fill
+square.stack.fill
+square.text.square
+square.text.square.fill
+square.tophalf.fill
+square.tophalf.filled
+square.topthird.inset.filled
+square.trailingthird.inset.filled
+squareroot
+squares.below.rectangle
+squares.leading.rectangle
+squares.leading.rectangle.fill
+squareshape
+squareshape.controlhandles.on.squareshape.controlhandles
+squareshape.dashed.squareshape
+squareshape.dotted.split.2x2
+squareshape.dotted.squareshape
+squareshape.fill
+squareshape.split.2x2
+squareshape.split.2x2.dotted
+squareshape.split.2x2.dotted.inside
+squareshape.split.2x2.dotted.inside.and.outside
+squareshape.split.2x2.dotted.outside
+squareshape.split.3x3
+squareshape.squareshape.dashed
+squareshape.squareshape.dotted
+stairs
+star
+star.bubble
+star.bubble.fill
+star.circle
+star.circle.fill
+star.fill
+star.hexagon
+star.hexagon.fill
+star.leadinghalf.fill
+star.leadinghalf.filled
+star.lefthalf.fill
+star.rectangle
+star.rectangle.fill
+star.slash
+star.slash.fill
+star.square
+star.square.fill
+star.square.on.square
+star.square.on.square.fill
+staroflife
+staroflife.circle
+staroflife.circle.fill
+staroflife.fill
+staroflife.shield
+staroflife.shield.fill
+steeringwheel
+steeringwheel.and.hands
+steeringwheel.and.heat.waves
+steeringwheel.and.key
+steeringwheel.and.liquid.wave
+steeringwheel.and.lock
+steeringwheel.arrow.trianglehead.counterclockwise.and.clockwise
+steeringwheel.arrowtriangle.left
+steeringwheel.arrowtriangle.right
+steeringwheel.badge.exclamationmark
+steeringwheel.badge.lock
+steeringwheel.circle
+steeringwheel.circle.fill
+steeringwheel.exclamationmark
+steeringwheel.road.lane
+steeringwheel.road.lane.dashed
+steeringwheel.slash
+sterlingsign
+sterlingsign.arrow.circlepath
+sterlingsign.arrow.trianglehead.counterclockwise.rotate.90
+sterlingsign.bank.building
+sterlingsign.bank.building.fill
+sterlingsign.circle
+sterlingsign.circle.fill
+sterlingsign.gauge.chart.lefthalf.righthalf
+sterlingsign.gauge.chart.leftthird.topthird.rightthird
+sterlingsign.ring
+sterlingsign.ring.dashed
+sterlingsign.square
+sterlingsign.square.fill
+stethoscope
+stethoscope.circle
+stethoscope.circle.fill
+stop
+stop.circle
+stop.circle.fill
+stop.fill
+stopwatch
+stopwatch.fill
+storefront
+storefront.circle
+storefront.circle.fill
+storefront.fill
+stove
+stove.fill
+strikethrough
+strikethrough.double
+stroke.line.diagonal
+stroke.line.diagonal.slash
+stroller
+stroller.fill
+studentdesk
+suit.club
+suit.club.fill
+suit.diamond
+suit.diamond.fill
+suit.heart
+suit.heart.fill
+suit.spade
+suit.spade.fill
+suitcase
+suitcase.cart
+suitcase.cart.fill
+suitcase.circle
+suitcase.circle.fill
+suitcase.fill
+suitcase.rolling
+suitcase.rolling.and.film
+suitcase.rolling.and.film.circle
+suitcase.rolling.and.film.circle.fill
+suitcase.rolling.and.film.fill
+suitcase.rolling.and.suitcase
+suitcase.rolling.and.suitcase.circle
+suitcase.rolling.and.suitcase.circle.fill
+suitcase.rolling.and.suitcase.fill
+suitcase.rolling.circle
+suitcase.rolling.circle.fill
+suitcase.rolling.fill
+sum
+sun.and.horizon
+sun.and.horizon.circle
+sun.and.horizon.circle.fill
+sun.and.horizon.fill
+sun.dust
+sun.dust.circle
+sun.dust.circle.fill
+sun.dust.fill
+sun.haze
+sun.haze.circle
+sun.haze.circle.fill
+sun.haze.fill
+sun.horizon
+sun.horizon.circle
+sun.horizon.circle.fill
+sun.horizon.fill
+sun.lefthalf.filled
+sun.max
+sun.max.circle
+sun.max.circle.fill
+sun.max.fill
+sun.max.trianglebadge.exclamationmark
+sun.max.trianglebadge.exclamationmark.fill
+sun.min
+sun.min.fill
+sun.rain
+sun.rain.circle
+sun.rain.circle.fill
+sun.rain.fill
+sun.righthalf.filled
+sun.snow
+sun.snow.circle
+sun.snow.circle.fill
+sun.snow.fill
+sunglasses
+sunglasses.fill
+sunrise
+sunrise.circle
+sunrise.circle.fill
+sunrise.fill
+sunset
+sunset.circle
+sunset.circle.fill
+sunset.fill
+surfboard
+surfboard.fill
+suspension.shock
+suv.side
+suv.side.air.circulate
+suv.side.air.circulate.fill
+suv.side.air.fresh
+suv.side.air.fresh.fill
+suv.side.and.exclamationmark
+suv.side.and.exclamationmark.fill
+suv.side.arrow.left.and.right
+suv.side.arrow.left.and.right.fill
+suv.side.arrowtriangle.down
+suv.side.arrowtriangle.down.fill
+suv.side.arrowtriangle.up
+suv.side.arrowtriangle.up.arrowtriangle.down
+suv.side.arrowtriangle.up.arrowtriangle.down.fill
+suv.side.arrowtriangle.up.fill
+suv.side.fill
+suv.side.front.open
+suv.side.front.open.crop
+suv.side.front.open.crop.fill
+suv.side.front.open.fill
+suv.side.hill.descent.control
+suv.side.hill.descent.control.fill
+suv.side.hill.down
+suv.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle
+suv.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle.fill
+suv.side.hill.down.fill
+suv.side.hill.up
+suv.side.hill.up.fill
+suv.side.lock
+suv.side.lock.fill
+suv.side.lock.open
+suv.side.lock.open.fill
+suv.side.rear.open
+suv.side.rear.open.crop
+suv.side.rear.open.crop.fill
+suv.side.rear.open.fill
+suv.side.roof.cargo.carrier
+suv.side.roof.cargo.carrier.fill
+suv.side.roof.cargo.carrier.slash
+suv.side.roof.cargo.carrier.slash.fill
+swatchpalette
+swatchpalette.fill
+swedishkronasign
+swedishkronasign.arrow.circlepath
+swedishkronasign.arrow.trianglehead.counterclockwise.rotate.90
+swedishkronasign.bank.building
+swedishkronasign.bank.building.fill
+swedishkronasign.circle
+swedishkronasign.circle.fill
+swedishkronasign.gauge.chart.lefthalf.righthalf
+swedishkronasign.gauge.chart.leftthird.topthird.rightthird
+swedishkronasign.ring
+swedishkronasign.ring.dashed
+swedishkronasign.square
+swedishkronasign.square.fill
+swift
+swiftdata
+swirl.circle.righthalf.filled
+swirl.circle.righthalf.filled.inverse
+switch.2
+switch.programmable
+switch.programmable.fill
+switch.programmable.square
+switch.programmable.square.fill
+syringe
+syringe.fill
+t.bubble
+t.bubble.fill
+t.circle
+t.circle.fill
+t.square
+t.square.fill
+table
+table.badge.more
+table.badge.more.fill
+table.fill
+table.furniture
+table.furniture.fill
+tablecells
+tablecells.badge.ellipsis
+tablecells.badge.ellipsis.fill
+tablecells.fill
+tablecells.fill.badge.ellipsis
+tachometer
+tag
+tag.circle
+tag.circle.fill
+tag.fill
+tag.slash
+tag.slash.fill
+tag.square
+tag.square.fill
+taillight.fog
+taillight.fog.fill
+takeoutbag.and.cup.and.straw
+takeoutbag.and.cup.and.straw.fill
+target
+teddybear
+teddybear.fill
+teletype
+teletype.answer
+teletype.answer.circle
+teletype.answer.circle.fill
+teletype.circle
+teletype.circle.fill
+tengesign
+tengesign.arrow.circlepath
+tengesign.arrow.trianglehead.counterclockwise.rotate.90
+tengesign.bank.building
+tengesign.bank.building.fill
+tengesign.circle
+tengesign.circle.fill
+tengesign.gauge.chart.lefthalf.righthalf
+tengesign.gauge.chart.leftthird.topthird.rightthird
+tengesign.ring
+tengesign.ring.dashed
+tengesign.square
+tengesign.square.fill
+tennis.racket
+tennis.racket.circle
+tennis.racket.circle.fill
+tennisball
+tennisball.circle
+tennisball.circle.fill
+tennisball.fill
+tent
+tent.2
+tent.2.circle
+tent.2.circle.fill
+tent.2.fill
+tent.circle
+tent.circle.fill
+tent.fill
+terminal
+terminal.fill
+testtube.2
+text.aligncenter
+text.alignleft
+text.alignright
+text.and.command.macwindow
+text.append
+text.badge.checkmark
+text.badge.minus
+text.badge.plus
+text.badge.star
+text.badge.xmark
+text.below.folder
+text.below.folder.fill
+text.below.photo
+text.below.photo.fill
+text.book.closed
+text.book.closed.fill
+text.bubble
+text.bubble.badge.clock
+text.bubble.badge.clock.fill
+text.bubble.fill
+text.cursor
+text.document
+text.document.fill
+text.insert
+text.justify
+text.justify.leading
+text.justify.left
+text.justify.right
+text.justify.trailing
+text.justifyleft
+text.justifyright
+text.line.2.summary
+text.line.2.summary.badge.xmark
+text.line.3.summary
+text.line.first.and.arrowtriangle.forward
+text.line.last.and.arrowtriangle.forward
+text.line.magnify
+text.magnifyingglass
+text.pad.header
+text.pad.header.badge.clock
+text.pad.header.badge.plus
+text.page
+text.page.badge.magnifyingglass
+text.page.fill
+text.page.slash
+text.page.slash.fill
+text.quote
+text.rectangle
+text.rectangle.fill
+text.rectangle.page
+text.rectangle.page.fill
+text.redaction
+text.square.filled
+text.viewfinder
+text.word.spacing
+textbox
+textformat
+textformat.12
+textformat.12.km
+textformat.12.my
+textformat.123
+textformat.abc
+textformat.abc.dottedunderline
+textformat.alt
+textformat.alt.bn
+textformat.alt.gu
+textformat.alt.kn
+textformat.alt.ml
+textformat.alt.mni
+textformat.alt.mr
+textformat.alt.or
+textformat.alt.pa
+textformat.alt.sat
+textformat.alt.si
+textformat.alt.ta
+textformat.alt.te
+textformat.bn
+textformat.characters
+textformat.characters.arrow.left.and.right
+textformat.characters.arrow.left.and.right.bn
+textformat.characters.arrow.left.and.right.gu
+textformat.characters.arrow.left.and.right.kn
+textformat.characters.arrow.left.and.right.ml
+textformat.characters.arrow.left.and.right.mni
+textformat.characters.arrow.left.and.right.mr
+textformat.characters.arrow.left.and.right.or
+textformat.characters.arrow.left.and.right.pa
+textformat.characters.arrow.left.and.right.ru
+textformat.characters.arrow.left.and.right.sat
+textformat.characters.arrow.left.and.right.si
+textformat.characters.arrow.left.and.right.ta
+textformat.characters.arrow.left.and.right.te
+textformat.characters.bn
+textformat.characters.dottedunderline
+textformat.characters.dottedunderline.bn
+textformat.characters.dottedunderline.gu
+textformat.characters.dottedunderline.kn
+textformat.characters.dottedunderline.ml
+textformat.characters.dottedunderline.mni
+textformat.characters.dottedunderline.mr
+textformat.characters.dottedunderline.or
+textformat.characters.dottedunderline.pa
+textformat.characters.dottedunderline.ru
+textformat.characters.dottedunderline.sat
+textformat.characters.dottedunderline.si
+textformat.characters.dottedunderline.ta
+textformat.characters.dottedunderline.te
+textformat.characters.gu
+textformat.characters.kn
+textformat.characters.ml
+textformat.characters.mni
+textformat.characters.mr
+textformat.characters.or
+textformat.characters.pa
+textformat.characters.ru
+textformat.characters.sat
+textformat.characters.si
+textformat.characters.ta
+textformat.characters.te
+textformat.gu
+textformat.kn
+textformat.ml
+textformat.mni
+textformat.mr
+textformat.numbers
+textformat.numbers.bn
+textformat.numbers.gu
+textformat.numbers.km
+textformat.numbers.kn
+textformat.numbers.ml
+textformat.numbers.mni
+textformat.numbers.mr
+textformat.numbers.my
+textformat.numbers.or
+textformat.numbers.pa
+textformat.numbers.sat
+textformat.numbers.te
+textformat.or
+textformat.pa
+textformat.sat
+textformat.si
+textformat.size
+textformat.size.bn
+textformat.size.gu
+textformat.size.kn
+textformat.size.larger
+textformat.size.larger.bn
+textformat.size.larger.gu
+textformat.size.larger.kn
+textformat.size.larger.ml
+textformat.size.larger.mni
+textformat.size.larger.mr
+textformat.size.larger.or
+textformat.size.larger.pa
+textformat.size.larger.sat
+textformat.size.larger.si
+textformat.size.larger.ta
+textformat.size.larger.te
+textformat.size.ml
+textformat.size.mni
+textformat.size.mr
+textformat.size.or
+textformat.size.pa
+textformat.size.sat
+textformat.size.si
+textformat.size.smaller
+textformat.size.smaller.bn
+textformat.size.smaller.gu
+textformat.size.smaller.kn
+textformat.size.smaller.ml
+textformat.size.smaller.mni
+textformat.size.smaller.mr
+textformat.size.smaller.or
+textformat.size.smaller.pa
+textformat.size.smaller.sat
+textformat.size.smaller.si
+textformat.size.smaller.ta
+textformat.size.smaller.te
+textformat.size.ta
+textformat.size.te
+textformat.subscript
+textformat.subscript.bn
+textformat.subscript.gu
+textformat.subscript.kn
+textformat.subscript.ml
+textformat.subscript.mni
+textformat.subscript.mr
+textformat.subscript.or
+textformat.subscript.pa
+textformat.subscript.sat
+textformat.subscript.si
+textformat.subscript.ta
+textformat.subscript.te
+textformat.superscript
+textformat.superscript.bn
+textformat.superscript.gu
+textformat.superscript.kn
+textformat.superscript.ml
+textformat.superscript.mni
+textformat.superscript.mr
+textformat.superscript.or
+textformat.superscript.pa
+textformat.superscript.sat
+textformat.superscript.si
+textformat.superscript.ta
+textformat.superscript.te
+textformat.ta
+textformat.te
+theatermask.and.paintbrush
+theatermask.and.paintbrush.fill
+theatermasks
+theatermasks.circle
+theatermasks.circle.fill
+theatermasks.fill
+thermometer
+thermometer.and.ellipsis
+thermometer.and.liquid.waves
+thermometer.and.liquid.waves.snowflake
+thermometer.and.liquid.waves.trianglebadge.exclamationmark
+thermometer.brakesignal
+thermometer.gauge.open
+thermometer.high
+thermometer.low
+thermometer.medium
+thermometer.medium.slash
+thermometer.snowflake
+thermometer.snowflake.circle
+thermometer.snowflake.circle.fill
+thermometer.sun
+thermometer.sun.circle
+thermometer.sun.circle.fill
+thermometer.sun.fill
+thermometer.tirepressure
+thermometer.transmission
+thermometer.variable
+thermometer.variable.and.figure
+thermometer.variable.and.figure.circle
+thermometer.variable.and.figure.circle.fill
+thermometer.variable.badge.clock
+thermometer.variable.badge.play
+ticket
+ticket.circle
+ticket.circle.fill
+ticket.fill
+timelapse
+timeline.selection
+timer
+timer.circle
+timer.circle.fill
+timer.square
+tire
+tire.badge.snowflake
+tirepressure
+togglepower
+toilet
+toilet.circle
+toilet.circle.fill
+toilet.fill
+tornado
+tornado.circle
+tornado.circle.fill
+tortoise
+tortoise.circle
+tortoise.circle.fill
+tortoise.fill
+torus
+touchid
+tow.hitch
+tow.hitch.exclamationmark
+tow.hitch.exclamationmark.fill
+tow.hitch.fill
+traction.control.tirepressure
+traction.control.tirepressure.exclamationmark
+traction.control.tirepressure.slash
+train.side.front.car
+train.side.middle.car
+train.side.rear.car
+tram
+tram.card
+tram.card.fill
+tram.circle
+tram.circle.fill
+tram.fill
+tram.fill.tunnel
+tram.tunnel.fill
+translate
+transmission
+trapezoid.and.line.horizontal
+trapezoid.and.line.horizontal.fill
+trapezoid.and.line.vertical
+trapezoid.and.line.vertical.fill
+trash
+trash.circle
+trash.circle.fill
+trash.fill
+trash.slash
+trash.slash.circle
+trash.slash.circle.fill
+trash.slash.fill
+trash.slash.square
+trash.slash.square.fill
+trash.square
+trash.square.fill
+tray
+tray.2
+tray.2.fill
+tray.and.arrow.down
+tray.and.arrow.down.fill
+tray.and.arrow.up
+tray.and.arrow.up.fill
+tray.badge
+tray.badge.fill
+tray.circle
+tray.circle.fill
+tray.fill
+tray.full
+tray.full.fill
+tree
+tree.circle
+tree.circle.fill
+tree.fill
+triangle
+triangle.bottomhalf.filled
+triangle.circle
+triangle.circle.fill
+triangle.fill
+triangle.inset.filled
+triangle.lefthalf.fill
+triangle.lefthalf.filled
+triangle.righthalf.fill
+triangle.righthalf.filled
+triangle.tophalf.filled
+triangleshape
+triangleshape.fill
+trophy
+trophy.circle
+trophy.circle.fill
+trophy.fill
+tropicalstorm
+tropicalstorm.circle
+tropicalstorm.circle.fill
+truck.box
+truck.box.badge.clock
+truck.box.badge.clock.fill
+truck.box.fill
+truck.pickup.side
+truck.pickup.side.air.circulate
+truck.pickup.side.air.circulate.fill
+truck.pickup.side.air.fresh
+truck.pickup.side.air.fresh.fill
+truck.pickup.side.and.exclamationmark
+truck.pickup.side.and.exclamationmark.fill
+truck.pickup.side.arrow.left.and.right
+truck.pickup.side.arrow.left.and.right.fill
+truck.pickup.side.arrowtriangle.down
+truck.pickup.side.arrowtriangle.down.fill
+truck.pickup.side.arrowtriangle.up
+truck.pickup.side.arrowtriangle.up.arrowtriangle.down
+truck.pickup.side.arrowtriangle.up.arrowtriangle.down.fill
+truck.pickup.side.arrowtriangle.up.fill
+truck.pickup.side.fill
+truck.pickup.side.front.open
+truck.pickup.side.front.open.crop
+truck.pickup.side.front.open.crop.fill
+truck.pickup.side.front.open.fill
+truck.pickup.side.hill.down
+truck.pickup.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle
+truck.pickup.side.hill.down.and.gauge.open.with.lines.needle.25percent.and.arrowtriangle.fill
+truck.pickup.side.hill.down.fill
+truck.pickup.side.hill.up
+truck.pickup.side.hill.up.fill
+truck.pickup.side.lock
+truck.pickup.side.lock.fill
+truck.pickup.side.lock.open
+truck.pickup.side.lock.open.fill
+truck.side.hill.descent.control
+truck.side.hill.descent.control.fill
+truck.side.roof.cargo.carrier
+truck.side.roof.cargo.carrier.fill
+truck.side.roof.cargo.carrier.slash
+truck.side.roof.cargo.carrier.slash.fill
+tsa
+tsa.circle
+tsa.circle.fill
+tsa.slash
+tshirt
+tshirt.circle
+tshirt.circle.fill
+tshirt.fill
+tugriksign
+tugriksign.arrow.circlepath
+tugriksign.arrow.trianglehead.counterclockwise.rotate.90
+tugriksign.bank.building
+tugriksign.bank.building.fill
+tugriksign.circle
+tugriksign.circle.fill
+tugriksign.gauge.chart.lefthalf.righthalf
+tugriksign.gauge.chart.leftthird.topthird.rightthird
+tugriksign.ring
+tugriksign.ring.dashed
+tugriksign.square
+tugriksign.square.fill
+tuningfork
+turkishlirasign
+turkishlirasign.arrow.circlepath
+turkishlirasign.arrow.trianglehead.counterclockwise.rotate.90
+turkishlirasign.bank.building
+turkishlirasign.bank.building.fill
+turkishlirasign.circle
+turkishlirasign.circle.fill
+turkishlirasign.gauge.chart.lefthalf.righthalf
+turkishlirasign.gauge.chart.leftthird.topthird.rightthird
+turkishlirasign.ring
+turkishlirasign.ring.dashed
+turkishlirasign.square
+turkishlirasign.square.fill
+tv
+tv.and.hifispeaker.fill
+tv.and.mediabox
+tv.and.mediabox.fill
+tv.badge.wifi
+tv.badge.wifi.fill
+tv.circle
+tv.circle.fill
+tv.fill
+tv.inset.filled
+tv.music.note
+tv.music.note.fill
+tv.slash
+tv.slash.fill
+u.circle
+u.circle.fill
+u.square
+u.square.fill
+uiwindow.split.2x1
+umbrella
+umbrella.circle
+umbrella.circle.fill
+umbrella.fill
+umbrella.gauge.open
+umbrella.percent
+umbrella.percent.fill
+umbrella.sensor.tag.radiowaves.left.and.right
+umbrella.sensor.tag.radiowaves.left.and.right.fill
+underline
+underline.double
+v.circle
+v.circle.fill
+v.square
+v.square.fill
+vent.heat.waves.upward
+vial.viewfinder
+video
+video.and.waveform
+video.and.waveform.fill
+video.badge.checkmark
+video.badge.ellipsis
+video.badge.plus
+video.badge.plus.fill
+video.badge.waveform
+video.badge.waveform.fill
+video.bubble
+video.bubble.fill
+video.bubble.left
+video.bubble.left.fill
+video.circle
+video.circle.fill
+video.doorbell
+video.doorbell.fill
+video.fill
+video.fill.badge.checkmark
+video.fill.badge.ellipsis
+video.fill.badge.plus
+video.slash
+video.slash.circle
+video.slash.circle.fill
+video.slash.fill
+video.square
+video.square.fill
+videoprojector
+videoprojector.fill
+view.2d
+view.3d
+viewfinder
+viewfinder.circle
+viewfinder.circle.fill
+viewfinder.rectangular
+viewfinder.trianglebadge.exclamationmark
+vision.pro
+vision.pro.and.arrow.forward
+vision.pro.and.arrow.forward.fill
+vision.pro.badge.checkmark
+vision.pro.badge.checkmark.fill
+vision.pro.badge.exclamationmark
+vision.pro.badge.exclamationmark.fill
+vision.pro.badge.play
+vision.pro.badge.play.fill
+vision.pro.circle
+vision.pro.circle.fill
+vision.pro.fill
+vision.pro.slash
+vision.pro.slash.circle
+vision.pro.slash.circle.fill
+vision.pro.slash.fill
+vision.pro.trianglebadge.exclamationmark
+vision.pro.trianglebadge.exclamationmark.fill
+visionpro
+visionpro.and.arrow.forward
+visionpro.and.arrow.forward.fill
+visionpro.badge.exclamationmark
+visionpro.badge.exclamationmark.fill
+visionpro.badge.play
+visionpro.badge.play.fill
+visionpro.circle
+visionpro.circle.fill
+visionpro.fill
+visionpro.slash
+visionpro.slash.circle
+visionpro.slash.circle.fill
+visionpro.slash.fill
+voiceover
+volleyball
+volleyball.circle
+volleyball.circle.fill
+volleyball.fill
+w.circle
+w.circle.fill
+w.square
+w.square.fill
+wake
+wake.circle
+wake.circle.fill
+wallet.bifold
+wallet.bifold.fill
+wallet.pass
+wallet.pass.fill
+wallet.sensor.tag.radiowaves.left.and.right
+wallet.sensor.tag.radiowaves.left.and.right.fill
+wand.and.outline
+wand.and.outline.inverse
+wand.and.rays
+wand.and.rays.inverse
+wand.and.sparkles
+wand.and.sparkles.inverse
+wand.and.stars
+wand.and.stars.inverse
+warninglight
+warninglight.fill
+washer
+washer.circle
+washer.circle.fill
+washer.fill
+watch.analog
+watchface.applewatch.case
+water.waves
+water.waves.and.arrow.down
+water.waves.and.arrow.down.trianglebadge.exclamationmark
+water.waves.and.arrow.trianglehead.down
+water.waves.and.arrow.trianglehead.down.trianglebadge.exclamationmark
+water.waves.and.arrow.trianglehead.up
+water.waves.and.arrow.up
+water.waves.slash
+waterbottle
+waterbottle.fill
+wave.3.backward
+wave.3.backward.circle
+wave.3.backward.circle.fill
+wave.3.down
+wave.3.down.car.side
+wave.3.down.car.side.fill
+wave.3.down.circle
+wave.3.down.circle.fill
+wave.3.down.convertible.side
+wave.3.down.convertible.side.fill
+wave.3.down.pickup.side
+wave.3.down.pickup.side.fill
+wave.3.down.suv.side
+wave.3.down.suv.side.fill
+wave.3.forward
+wave.3.forward.circle
+wave.3.forward.circle.fill
+wave.3.left
+wave.3.left.circle
+wave.3.left.circle.fill
+wave.3.right
+wave.3.right.circle
+wave.3.right.circle.fill
+wave.3.up
+wave.3.up.circle
+wave.3.up.circle.fill
+waveform
+waveform.and.magnifyingglass
+waveform.and.mic
+waveform.and.person.filled
+waveform.badge.checkmark
+waveform.badge.exclamationmark
+waveform.badge.magnifyingglass
+waveform.badge.mic
+waveform.badge.microphone
+waveform.badge.minus
+waveform.badge.plus
+waveform.badge.xmark
+waveform.circle
+waveform.circle.fill
+waveform.low
+waveform.mid
+waveform.path
+waveform.path.badge.minus
+waveform.path.badge.plus
+waveform.path.ecg
+waveform.path.ecg.magnifyingglass
+waveform.path.ecg.rectangle
+waveform.path.ecg.rectangle.fill
+waveform.path.ecg.text
+waveform.path.ecg.text.clipboard
+waveform.path.ecg.text.clipboard.fill
+waveform.path.ecg.text.page
+waveform.path.ecg.text.page.fill
+waveform.slash
+web.camera
+web.camera.fill
+wheelchair
+widget.extralarge
+widget.extralarge.badge.plus
+widget.large
+widget.large.badge.plus
+widget.medium
+widget.medium.badge.plus
+widget.small
+widget.small.badge.plus
+wifi
+wifi.badge.lock
+wifi.circle
+wifi.circle.fill
+wifi.exclamationmark
+wifi.exclamationmark.circle
+wifi.exclamationmark.circle.fill
+wifi.router
+wifi.router.fill
+wifi.slash
+wifi.square
+wifi.square.fill
+wind
+wind.circle
+wind.circle.fill
+wind.snow
+wind.snow.circle
+wind.snow.circle.fill
+window.awning
+window.awning.closed
+window.casement
+window.casement.closed
+window.ceiling
+window.ceiling.closed
+window.horizontal
+window.horizontal.closed
+window.shade.closed
+window.shade.open
+window.vertical.closed
+window.vertical.open
+windshield.front.and.fluid
+windshield.front.and.fluid.and.spray
+windshield.front.and.heat.waves
+windshield.front.and.spray
+windshield.front.and.wiper
+windshield.front.and.wiper.and.drop
+windshield.front.and.wiper.and.spray
+windshield.front.and.wiper.exclamationmark
+windshield.front.and.wiper.intermittent
+windshield.rear.and.fluid
+windshield.rear.and.fluid.and.spray
+windshield.rear.and.heat.waves
+windshield.rear.and.spray
+windshield.rear.and.wiper
+windshield.rear.and.wiper.and.drop
+windshield.rear.and.wiper.and.spray
+windshield.rear.and.wiper.exclamationmark
+windshield.rear.and.wiper.intermittent
+wineglass
+wineglass.fill
+wonsign
+wonsign.arrow.circlepath
+wonsign.arrow.trianglehead.counterclockwise.rotate.90
+wonsign.bank.building
+wonsign.bank.building.fill
+wonsign.circle
+wonsign.circle.fill
+wonsign.gauge.chart.lefthalf.righthalf
+wonsign.gauge.chart.leftthird.topthird.rightthird
+wonsign.ring
+wonsign.ring.dashed
+wonsign.square
+wonsign.square.fill
+wrench
+wrench.adjustable
+wrench.adjustable.fill
+wrench.and.screwdriver
+wrench.and.screwdriver.fill
+wrench.fill
+wrongwaysign
+wrongwaysign.fill
+x.circle
+x.circle.fill
+x.square
+x.square.fill
+x.squareroot
+xbox.logo
+xmark
+xmark.app
+xmark.app.fill
+xmark.bin
+xmark.bin.circle
+xmark.bin.circle.fill
+xmark.bin.fill
+xmark.circle
+xmark.circle.badge.airplane
+xmark.circle.badge.airplane.fill
+xmark.circle.fill
+xmark.diamond
+xmark.diamond.fill
+xmark.icloud
+xmark.icloud.fill
+xmark.octagon
+xmark.octagon.fill
+xmark.rectangle
+xmark.rectangle.fill
+xmark.rectangle.portrait
+xmark.rectangle.portrait.fill
+xmark.seal
+xmark.seal.fill
+xmark.shield
+xmark.shield.fill
+xmark.square
+xmark.square.fill
+xmark.triangle.circle.square
+xmark.triangle.circle.square.fill
+xserve
+xserve.raid
+y.circle
+y.circle.fill
+y.square
+y.square.fill
+yensign
+yensign.arrow.circlepath
+yensign.arrow.trianglehead.counterclockwise.rotate.90
+yensign.bank.building
+yensign.bank.building.fill
+yensign.circle
+yensign.circle.fill
+yensign.gauge.chart.lefthalf.righthalf
+yensign.gauge.chart.leftthird.topthird.rightthird
+yensign.ring
+yensign.ring.dashed
+yensign.square
+yensign.square.fill
+yieldsign
+yieldsign.fill
+z.circle
+z.circle.fill
+z.square
+z.square.fill
+zipper.page
+zl.button.roundedtop.horizontal
+zl.button.roundedtop.horizontal.fill
+zl.rectangle.roundedtop
+zl.rectangle.roundedtop.fill
+zr.button.roundedtop.horizontal
+zr.button.roundedtop.horizontal.fill
+zr.rectangle.roundedtop
+zr.rectangle.roundedtop.fill
+zzz
+"""
+}
+
+private struct SkillSymbolPicker: View {
+ @Binding var selection: String
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.themePalette) private var palette
+ @State private var query = ""
+
+ private let columns = [
+ GridItem(.adaptive(minimum: 44), spacing: 6),
+ ]
+
+ private var filtered: [String] {
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return SFSymbolCatalog.names }
+ return SFSymbolCatalog.names.filter { $0.localizedCaseInsensitiveContains(trimmed) }
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ LazyVGrid(columns: columns, spacing: 6) {
+ ForEach(filtered, id: \.self) { symbol in
+ Button {
+ selection = symbol
+ dismiss()
+ } label: {
+ Image(systemName: symbol)
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(
+ symbol == selection ? palette.textOnAccent : palette.textPrimary
+ )
+ .frame(width: 44, height: 44)
+ .background(
+ symbol == selection ? palette.accent : palette.surfaceElevated,
+ in: RoundedRectangle(cornerRadius: 8, style: .continuous)
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(Text(symbol))
+ }
+ }
+ .padding(.horizontal, Spacing.md)
+ .padding(.vertical, Spacing.sm)
+ }
+ .background(palette.background)
+ .navigationTitle("skills.editor.icon")
+ .navigationBarTitleDisplayMode(.inline)
+ .searchable(text: $query, prompt: Text("skills.editor.iconSearch"))
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("common.done") { dismiss() }
+ }
+ }
+ }
+ .presentationDetents([.large])
+ }
+}
diff --git a/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift b/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift
new file mode 100644
index 0000000..40f0e05
--- /dev/null
+++ b/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift
@@ -0,0 +1,136 @@
+// AIClipboardSkillLayoutDemoView.swift
+// OSGKeyboard · Main App (DEBUG-only)
+//
+// Interactive layout preview of AI Agent clipboard skills on the real
+// `AIKeyboardView`. Launch with `--ai-skills-demo` and optional
+// `--skills-count=N` (1...8).
+
+#if DEBUG
+import SwiftUI
+import OSGKeyboardShared
+
+struct AIClipboardSkillLayoutDemoView: View {
+ @StateObject private var state = KeyboardState()
+ @StateObject private var typing = TypingSessionController()
+ @State private var count: Int = Self.initialCount
+
+ var body: some View {
+ VStack(spacing: 0) {
+ controls
+ Spacer(minLength: 0)
+ AIKeyboardView(
+ state: state,
+ typing: typing,
+ onInsert: { _ in }
+ )
+ .background(Palette.light.background)
+ }
+ .background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
+ .environment(\.locale, Locale(identifier: "zh-Hans"))
+ .preferredColorScheme(.light)
+ .onAppear { apply(count) }
+ .onChange(of: count) { _, newValue in apply(newValue) }
+ }
+
+ private var controls: some View {
+ VStack(spacing: 10) {
+ Text("技能布局预览")
+ .font(.headline)
+ .foregroundStyle(.white)
+ HStack(spacing: 16) {
+ Button {
+ count = max(1, count - 1)
+ } label: {
+ Image(systemName: "minus.circle.fill")
+ .font(.system(size: 28))
+ }
+ Text("\(count) 个")
+ .font(.title2.monospacedDigit().weight(.semibold))
+ .foregroundStyle(.white)
+ .frame(minWidth: 72)
+ Button {
+ count = min(8, count + 1)
+ } label: {
+ Image(systemName: "plus.circle.fill")
+ .font(.system(size: 28))
+ }
+ }
+ .foregroundStyle(.green)
+ HStack(spacing: 8) {
+ ForEach([3, 5, 8], id: \.self) { n in
+ Button("\(n)") {
+ count = n
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(count == n ? .green : .gray)
+ }
+ }
+ }
+ .padding(.top, 56)
+ .padding(.bottom, 16)
+ }
+
+ private func apply(_ count: Int) {
+ let clamped = min(max(count, 1), 8)
+ AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
+ state.surface = .ai
+ state.aiServiceAvailable = true
+ state.micDisabled = false
+ state.layoutWidth = 390
+ state.usesIPadLayoutMetrics = false
+ state.clipboardHistoryEnabled = true
+ state.enabledClipboardSkillIDs = Array(
+ AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
+ )
+ state.aiSession.enter()
+ }
+
+ private static var initialCount: Int {
+ let prefix = "--skills-count="
+ if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
+ let value = Int(arg.dropFirst(prefix.count)),
+ (1...8).contains(value) {
+ return value
+ }
+ return 5
+ }
+
+ private static func previewSkills(count: Int) -> [AIClipboardSkill] {
+ let catalog = AIClipboardSkillCatalog.catalog
+ if count <= catalog.count {
+ return Array(catalog.prefix(count))
+ }
+ var extras: [AIClipboardSkill] = [
+ AIClipboardSkill(
+ id: "preview-polish",
+ systemImage: "wand.and.stars",
+ titleKey: "keyboard.ai.skill.previewPolish",
+ cardTitleKey: "skills.reply.name",
+ descriptionKey: "skills.reply.description",
+ kind: .transform,
+ isDefault: false
+ ),
+ AIClipboardSkill(
+ id: "preview-ideas",
+ systemImage: "lightbulb.fill",
+ titleKey: "keyboard.ai.skill.previewIdeas",
+ cardTitleKey: "skills.summarize.name",
+ descriptionKey: "skills.summarize.description",
+ kind: .transform,
+ isDefault: false
+ ),
+ AIClipboardSkill(
+ id: "preview-tone",
+ systemImage: "theatermasks.fill",
+ titleKey: "keyboard.ai.skill.previewTone",
+ cardTitleKey: "skills.translate.name",
+ descriptionKey: "skills.translate.description",
+ kind: .transform,
+ isDefault: false
+ ),
+ ]
+ extras = Array(extras.prefix(count - catalog.count))
+ return catalog + extras
+ }
+}
+#endif
diff --git a/OSGKeyboard/Views/CatalogCardChrome.swift b/OSGKeyboard/Views/CatalogCardChrome.swift
new file mode 100644
index 0000000..38dc16c
--- /dev/null
+++ b/OSGKeyboard/Views/CatalogCardChrome.swift
@@ -0,0 +1,31 @@
+// CatalogCardChrome.swift
+// OSGKeyboard · Main App
+//
+// Shared edit / selected badges for style and skill cards.
+
+import SwiftUI
+import OSGKeyboardShared
+
+enum CatalogCardChrome {
+ static let badgeSize: CGFloat = 30
+ static let editIconSize: CGFloat = 15
+ static let checkIconSize: CGFloat = 21
+ static let editHitSize: CGFloat = 44
+
+ static func editIcon(palette: ThemePalette) -> some View {
+ Image(systemName: "pencil")
+ .font(.system(size: editIconSize, weight: .semibold))
+ .foregroundStyle(palette.textSecondary)
+ .frame(width: badgeSize, height: badgeSize)
+ .background(palette.background.opacity(0.75), in: Circle())
+ }
+
+ static func checkIcon(palette: ThemePalette) -> some View {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: checkIconSize, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .padding(Spacing.sm)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
+ .allowsHitTesting(false)
+ }
+}
diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift
index 91ce86e..c699c65 100644
--- a/OSGKeyboard/Views/Components/MinimalTabBar.swift
+++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
-// Bottom tab bar — icon + label for Home / Styles / Settings.
+// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
@@ -11,41 +11,41 @@ import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
+ case skills
case styles
case settings
- var icon: MaterialIconName {
- switch self {
- case .keyboard: return .keyboard
- case .styles: return .menuBook // unused — styles uses SF Symbol
- case .settings: return .settings
- }
- }
-
- /// SF Symbol overrides shared with the Mac and iPad sidebars.
- var sfSymbol: String? {
- switch self {
- case .styles: return "text.badge.star"
- default: return nil
- }
- }
-
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
+ case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
+ /// 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 .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"
}
}
}
@@ -65,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)
@@ -110,7 +104,7 @@ struct MinimalTabBar: View {
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
- .frame(maxWidth: 280)
+ .frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
diff --git a/OSGKeyboard/Views/Components/TabBarVisibility.swift b/OSGKeyboard/Views/Components/TabBarVisibility.swift
index 4b3cb16..c44af5d 100644
--- a/OSGKeyboard/Views/Components/TabBarVisibility.swift
+++ b/OSGKeyboard/Views/Components/TabBarVisibility.swift
@@ -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
diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift
index 8f97a6b..45f46cf 100644
--- a/OSGKeyboard/Views/MainAppRoot.swift
+++ b/OSGKeyboard/Views/MainAppRoot.swift
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
switch url.host {
case "startflow":
flowManager.startSession(coldStart: true, reason: "url.startflow")
+ case "skill":
+ if url.path.contains("shortcut-result") {
+ AIAgentShortcutRunner.logShortcutCallback(url)
+ } else if url.path.contains("run") {
+ AIAgentShortcutRunner.runPendingIfNeeded()
+ }
case "deployrime":
// The keyboard sends the user here precisely because typing
// resources are missing — deploy without waiting for warmup.
diff --git a/OSGKeyboard/Views/MainSplitView.swift b/OSGKeyboard/Views/MainSplitView.swift
index 728cba6..4c7d36b 100644
--- a/OSGKeyboard/Views/MainSplitView.swift
+++ b/OSGKeyboard/Views/MainSplitView.swift
@@ -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 {
diff --git a/OSGKeyboard/Views/MainTabContent.swift b/OSGKeyboard/Views/MainTabContent.swift
index 9a3b3de..df1014e 100644
--- a/OSGKeyboard/Views/MainTabContent.swift
+++ b/OSGKeyboard/Views/MainTabContent.swift
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
+ case .skills:
+ AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
diff --git a/OSGKeyboard/Views/PolishStylesView.swift b/OSGKeyboard/Views/PolishStylesView.swift
index 73dd189..74fd730 100644
--- a/OSGKeyboard/Views/PolishStylesView.swift
+++ b/OSGKeyboard/Views/PolishStylesView.swift
@@ -142,26 +142,14 @@ struct PolishStylesView: View {
editingPack = pack
}
} label: {
- Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(palette.textSecondary)
- .frame(width: 30, height: 30)
- .background(palette.background.opacity(0.75), in: Circle())
+ CatalogCardChrome.editIcon(palette: palette)
}
.padding(Spacing.sm)
.buttonStyle(.plain)
- .accessibilityLabel(
- Text(pack.kind == .builtin ? "polishStyles.viewPrompt" : "polishStyles.edit")
- )
+ .accessibilityLabel(Text("polishStyles.edit"))
if isSelected {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 21, weight: .semibold))
- .foregroundStyle(palette.accent)
- .background(Color.white, in: Circle())
- .padding(Spacing.sm)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
- .allowsHitTesting(false)
+ CatalogCardChrome.checkIcon(palette: palette)
}
}
.background(
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index df445f2..d684e4b 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -427,8 +427,74 @@
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.styles" = "Styles";
+"tab.skills" = "Skills";
"tab.settings" = "Settings";
+/* AI Agent skills */
+"skills.title" = "Skills";
+"skills.enabled.section" = "In use (%d/%d)";
+"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
+"skills.available.section" = "Available";
+"skills.clipboard.guide.title" = "Clipboard access needed";
+"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
+"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
+"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
+"skills.detail.title" = "Skill";
+"skills.badge.default" = "Default skill";
+"skills.action.turnOff" = "Turn off";
+"skills.action.turnOffHint" = "Removes it from the keyboard only.";
+"skills.action.addToKeyboard" = "Add to keyboard";
+"skills.action.reinstallShortcut" = "Reinstall Shortcut";
+"skills.full.title" = "Skill slots full";
+"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
+"skills.reply.name" = "Reply";
+"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
+"skills.summarize.name" = "Summarize";
+"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
+"skills.translate.name" = "Translate";
+"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
+"skills.extractTodos.name" = "Extract tasks";
+"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.";
+"skills.install.openShortcuts" = "Add Shortcut";
+"skills.install.confirmAdded" = "I've added it";
+"skills.add" = "Add skill";
+"skills.edit" = "Edit skill";
+"skills.custom.description" = "Custom clipboard skill";
+"skills.editor.name" = "Name";
+"skills.editor.namePlaceholder" = "Skill name";
+"skills.editor.summary" = "About";
+"skills.editor.summaryPlaceholder" = "Shown on the skill card";
+"skills.editor.icon" = "Icon";
+"skills.editor.iconChoose" = "Choose SF Symbol";
+"skills.editor.iconSearch" = "Search symbols";
+"skills.editor.prompt" = "Processing prompt";
+"skills.editor.thinking" = "Thinking";
+"skills.editor.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning for this skill.";
+"skills.editor.shortcut" = "Shortcut";
+"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
+"skills.editor.shortcutNamePlaceholder" = "Shortcut name (can differ from the skill name)";
+"skills.editor.shortcutHint" = "Paste an iCloud share link. The published Shortcut name is filled in automatically and you can change it. Don’t rename it in the Shortcuts app after adding.";
+"skills.editor.lookingUp" = "Looking up Shortcut name…";
+"skills.editor.resolvedName" = "Will run: %@";
+"skills.editor.lookupFailed" = "Couldn’t read the Shortcut name. Check the link, or type the name yourself.";
+"skills.delete.title" = "Delete this skill?";
+"skills.delete.message" = "It will be removed from this device. The Shortcut in the Shortcuts app is not deleted.";
+"skills.error.title" = "Couldn’t save skill";
+"skills.error.emptyName" = "Enter a skill name.";
+"skills.error.emptyPrompt" = "The prompt cannot be empty.";
+"skills.error.emptyShortcutName" = "Enter the Shortcut name used to run it.";
+"skills.error.invalidLink" = "Enter a valid iCloud Shortcuts link.";
+"skills.error.emptyIcon" = "Choose an icon.";
+"skills.error.promptTooLong" = "The prompt can contain up to 6,000 characters.";
+"skills.error.generic" = "Try again.";
+
/* Polish style packs */
"polishStyles.title" = "Polish styles";
"polishStyles.add" = "Add style";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index f41e7da..122ed85 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -426,8 +426,74 @@
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.styles" = "风格";
+"tab.skills" = "技能";
"tab.settings" = "设置";
+/* AI Agent 技能 */
+"skills.title" = "技能";
+"skills.enabled.section" = "使用中(%d/%d)";
+"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
+"skills.available.section" = "可添加";
+"skills.clipboard.guide.title" = "需要剪贴板权限";
+"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
+"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
+"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
+"skills.detail.title" = "技能";
+"skills.badge.default" = "默认技能";
+"skills.action.turnOff" = "关闭";
+"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
+"skills.action.addToKeyboard" = "添加到键盘";
+"skills.action.reinstallShortcut" = "重新安装捷径";
+"skills.full.title" = "技能已满";
+"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
+"skills.reply.name" = "回复";
+"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
+"skills.summarize.name" = "总结";
+"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
+"skills.translate.name" = "翻译";
+"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
+"skills.extractTodos.name" = "提取待办";
+"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" = "添加后点「我已添加」,请勿改名。";
+"skills.install.openShortcuts" = "添加捷径";
+"skills.install.confirmAdded" = "我已添加";
+"skills.add" = "添加技能";
+"skills.edit" = "编辑技能";
+"skills.custom.description" = "自定义剪贴板技能";
+"skills.editor.name" = "名称";
+"skills.editor.namePlaceholder" = "技能名称";
+"skills.editor.summary" = "介绍";
+"skills.editor.summaryPlaceholder" = "显示在技能卡片上";
+"skills.editor.icon" = "图标";
+"skills.editor.iconChoose" = "选择 SF Symbol";
+"skills.editor.iconSearch" = "搜索符号";
+"skills.editor.prompt" = "文本处理提示词";
+"skills.editor.thinking" = "思考";
+"skills.editor.thinkingHint" = "默认关闭。仅在需要该技能更慢、更深的推理时开启。";
+"skills.editor.shortcut" = "捷径";
+"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
+"skills.editor.shortcutNamePlaceholder" = "捷径名称(可与技能名称不同)";
+"skills.editor.shortcutHint" = "粘贴 iCloud 分享链接。发布名称会自动填入,也可以自行修改。添加到「快捷指令」后请勿改名。";
+"skills.editor.lookingUp" = "正在读取捷径名称…";
+"skills.editor.resolvedName" = "将运行:%@";
+"skills.editor.lookupFailed" = "无法读取捷径名称。请检查链接,或手动填写名称。";
+"skills.delete.title" = "删除此技能?";
+"skills.delete.message" = "会从本机移除。快捷指令 App 里的捷径不会被删除。";
+"skills.error.title" = "无法保存技能";
+"skills.error.emptyName" = "请输入技能名称。";
+"skills.error.emptyPrompt" = "提示词不能为空。";
+"skills.error.emptyShortcutName" = "请填写用于运行的捷径名称。";
+"skills.error.invalidLink" = "请输入有效的 iCloud 捷径链接。";
+"skills.error.emptyIcon" = "请选择图标。";
+"skills.error.promptTooLong" = "提示词最多 6,000 字。";
+"skills.error.generic" = "请再试一次。";
+
/* 润色风格包 */
"polishStyles.title" = "润色风格";
"polishStyles.add" = "添加风格";
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
index 42f608b..d833072 100644
--- a/OSGKeyboardExt/KeyboardViewController.swift
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
state.submitAIClipboardSkill = { [weak self] skill in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
}
+ state.runClipboardExportSkill = { [weak self] skillID, titles in
+ AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
+ AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
+ self?.openSkillShortcutRun()
+ }
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
state.openClipboardSettings = { [weak self] in
@@ -768,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
}
@@ -942,6 +967,19 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
+ private func openSkillShortcutRun() {
+ guard hasFullAccess else {
+ state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
+ return
+ }
+ guard let url = URL(string: "osgkeyboard://skill/run") else { return }
+ HostAppLauncher.open(url: url, from: self) { [weak self] success in
+ AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
+ if success { return }
+ self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
+ }
+ }
+
private func openHostApp(path: String = "settings") {
guard hasFullAccess else {
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
index ddaa662..2b23b86 100644
--- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
+++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
@@ -70,16 +70,32 @@ final class AIKeyboardCoordinator {
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
+ 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
)
+ AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
+ if let material {
+ AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
+ } else {
+ AIAgentShortcutRun.trace("keyboard.clipboard missing")
+ }
let resolution = AIClipboardPrompt.resolve(
instruction: instruction,
- material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ material: material
)
- submitResolvedPrompt(resolution)
+ if case .materialUnavailable = resolution {
+ AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
+ }
+ submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled)
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
@@ -103,27 +119,37 @@ final class AIKeyboardCoordinator {
}
}
- private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
+ private func submitResolvedPrompt(
+ _ resolution: AIClipboardPrompt.Resolution,
+ thinkingEnabled: Bool? = nil
+ ) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
+ clearPendingExportSkill()
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
- guard let conversationID = state.aiSession.conversationID else { return }
+ guard let conversationID = state.aiSession.conversationID else {
+ clearPendingExportSkill()
+ return
+ }
let disposition = flow.submitAIQuestion(
text: prompt,
- conversationID: conversationID
+ conversationID: conversationID,
+ thinkingEnabled: thinkingEnabled
)
if case .rejected(let rejection) = disposition {
+ clearPendingExportSkill()
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
+ clearPendingExportSkill()
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -132,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()
@@ -181,6 +207,7 @@ final class AIKeyboardCoordinator {
}
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
+ if isPendingExportSkill { return }
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
}
@@ -188,6 +215,11 @@ final class AIKeyboardCoordinator {
guard result.resolvedUtteranceMode == .aiQuestion else {
return
}
+ if isPendingExportSkill {
+ guard result.aiConversationID == state.aiSession.conversationID else { return }
+ finishExportSkill(answer: result.text ?? "")
+ return
+ }
guard result.aiConversationID == state.aiSession.conversationID,
let answer = result.text,
!answer.isEmpty else {
@@ -201,14 +233,105 @@ final class AIKeyboardCoordinator {
}
func fail(_ message: String, utteranceID: UUID?) {
+ clearPendingExportSkill()
state.aiSession.fail(message, utteranceID: utteranceID)
}
private func endConversationIfNeeded() {
+ 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
+ }
+
+ private func resolvedSkill(id: String) -> AIClipboardSkill? {
+ AIClipboardSkillCatalog.skill(
+ id: id,
+ userCatalog: AppGroupStore().agentUserSkillCatalog
+ )
+ }
+
+ /// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
+ /// Lines → hand off to the host (Shortcut, Maps, or Didi).
+ private func finishExportSkill(answer: String) {
+ let source = state.pendingClipboardSkillSource
+ ?? ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ let skillID = state.pendingClipboardSkillID
+ let items: [String]
+ let emptyTipKey: String
+ switch skillID {
+ case AIClipboardSkillCatalog.extractEventsID:
+ items = AIEventExtraction.lines(from: answer, sourceClipboard: source)
+ emptyTipKey = "keyboard.ai.skill.noEvents"
+ case AIClipboardSkillCatalog.extractTodosID:
+ items = AITodoExtraction.items(from: answer, sourceClipboard: source)
+ emptyTipKey = "keyboard.ai.skill.noTodos"
+ 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"
+ }
+ AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
+ AIAgentShortcutRun.trace(
+ "keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
+ )
+ #if DEBUG
+ if !items.isEmpty {
+ AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
+ }
+ #endif
+ clearPendingExportSkill()
+ if state.aiSession.isBusy {
+ state.aiSession.cancelCurrentWork()
+ }
+ if state.aiSession.isActive {
+ state.aiSession.resetConversationPreservingAnswer()
+ }
+ guard !items.isEmpty else {
+ AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
+ state.skillTipText = ExtL10n.string(emptyTipKey)
+ return
+ }
+ 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)")
+ 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)
+ }
+
private func message(for rejection: FlowUtteranceStartRejection) -> String {
switch rejection {
case .onboardingIncomplete:
diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift
index f66561e..a3df9d4 100644
--- a/OSGKeyboardExt/Services/AppGroupPersistor.swift
+++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
+ state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
+ state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
}
diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
index 261305f..0e52882 100644
--- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
+++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
@@ -670,12 +670,17 @@ final class KeyboardFlowCoordinator {
func submitAIQuestion(
text: String,
- conversationID: UUID
+ conversationID: UUID,
+ thinkingEnabled: Bool? = nil
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
return startUtterance(
- .aiQuestion(conversationID: conversationID, prefilledQuestion: trimmed)
+ .aiQuestion(
+ conversationID: conversationID,
+ prefilledQuestion: trimmed,
+ thinkingEnabled: thinkingEnabled
+ )
)
}
@@ -1868,6 +1873,7 @@ final class KeyboardFlowCoordinator {
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
+ aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
startDeadlineAt: currentStartDeadlineAt
)
FlowSessionBridge.writeCommand(command)
diff --git a/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift b/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift
index 976efe0..1e854a2 100644
--- a/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift
+++ b/OSGKeyboardExt/Typing/TypingKeyTouchPad.swift
@@ -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) -> 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?
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, 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, 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, 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, with event: UIEvent?) {
- coordinator?.handleCancelled()
+ for touch in Self.sorted(touches) {
+ coordinator?.handleCancelled(id: ObjectIdentifier(touch))
+ }
+ }
+
+ private static func sorted(_ touches: Set) -> [UITouch] {
+ touches.sorted { $0.timestamp < $1.timestamp }
}
}
diff --git a/OSGKeyboardExt/Typing/TypingRootView.swift b/OSGKeyboardExt/Typing/TypingRootView.swift
index 6675a8e..4309bbb 100644
--- a/OSGKeyboardExt/Typing/TypingRootView.swift
+++ b/OSGKeyboardExt/Typing/TypingRootView.swift
@@ -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 = []
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.
diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift
index fb43f32..d4850e7 100644
--- a/OSGKeyboardExt/Views/AIKeyboardView.swift
+++ b/OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -15,9 +15,18 @@ struct AIKeyboardView: View {
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
- static let skillButtonSize: CGFloat = 52
+ static let skillButtonSize: CGFloat = 48
+ static let skillsPerRow = 4
+ static let skillRowSpacing: CGFloat = 8
+ static let skillCellSpacing: CGFloat = 16
+ static let skillCellWidth: CGFloat = 64
}
+ #if DEBUG
+ /// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
+ static var debugPreviewSkills: [AIClipboardSkill]?
+ #endif
+
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@@ -55,6 +64,17 @@ struct AIKeyboardView: View {
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
+ .onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
+ .onChange(of: state.skillTipText) { _, tip in
+ guard let tip, !tip.isEmpty else { return }
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 2_800_000_000)
+ if state.skillTipText == tip {
+ state.skillTipText = nil
+ }
+ }
+ }
+ .animation(Motion.soft, value: state.skillTipText)
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
@@ -172,6 +192,19 @@ struct AIKeyboardView: View {
statusLine
.frame(height: Layout.statusHeight)
.padding(.horizontal, Spacing.md)
+
+ if let tip = state.skillTipText, !tip.isEmpty {
+ Text(tip)
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textPrimary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .glassEffect(.regular, in: Capsule())
+ .padding(.bottom, Layout.statusHeight + Spacing.sm)
+ .transition(.opacity)
+ .allowsHitTesting(false)
+ }
}
}
@@ -212,30 +245,52 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
- HStack(spacing: Spacing.lg) {
- ForEach(AIClipboardSkillCatalog.visible()) { skill in
- Button {
- state.submitAIClipboardSkill(skill)
- } label: {
- VStack(spacing: 6) {
- Image(systemName: skill.systemImage)
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(palette.textPrimary.opacity(0.85))
- .frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
- .glassEffect(.regular.interactive(), in: Circle())
- Text(clipboardSkillTitle(skill))
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textSecondary)
+ let rows = skillRows(from: visibleClipboardSkills)
+ return VStack(spacing: 0) {
+ Spacer(minLength: 0)
+ VStack(spacing: Layout.skillRowSpacing) {
+ ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
+ HStack(spacing: Layout.skillCellSpacing) {
+ ForEach(rowSkills) { skill in
+ skillChip(skill)
+ }
}
}
- .buttonStyle(.plain)
- .disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
- .accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
+ Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+ private func skillRows(from skills: [AIClipboardSkill]) -> [[AIClipboardSkill]] {
+ stride(from: 0, to: skills.count, by: Layout.skillsPerRow).map { start in
+ Array(skills[start ..< min(start + Layout.skillsPerRow, skills.count)])
+ }
+ }
+
+ private func skillChip(_ skill: AIClipboardSkill) -> some View {
+ Button {
+ state.submitAIClipboardSkill(skill)
+ } label: {
+ VStack(spacing: 6) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(palette.textPrimary.opacity(0.85))
+ .frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
+ .glassEffect(.regular.interactive(), in: Circle())
+ Text(clipboardSkillTitle(skill))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ }
+ .frame(width: Layout.skillCellWidth)
+ }
+ .buttonStyle(.plain)
+ .disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
+ .accessibilityLabel(Text(clipboardSkillTitle(skill)))
+ }
+
/// Translate follows the keyboard target; Reply / Summarize stay static.
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
@@ -244,18 +299,40 @@ struct AIKeyboardView: View {
uiLanguage: AppGroupStore().uiLanguage
)
}
+ if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !custom.isEmpty {
+ return custom
+ }
return ExtL10n.string(skill.titleKey)
}
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
+ #if DEBUG
+ if let preview = Self.debugPreviewSkills, !preview.isEmpty {
+ return showsPlaceholder
+ }
+ #endif
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
+ guard !visibleClipboardSkills.isEmpty else { return false }
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: clipboardHistory.newestEntry
)
}
+ private var visibleClipboardSkills: [AIClipboardSkill] {
+ #if DEBUG
+ if let preview = Self.debugPreviewSkills, !preview.isEmpty {
+ return preview
+ }
+ #endif
+ return AIClipboardSkillCatalog.visible(
+ enabledIDs: state.enabledClipboardSkillIDs,
+ userCatalog: AppGroupStore().agentUserSkillCatalog
+ )
+ }
+
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
@@ -432,6 +509,18 @@ struct AIKeyboardView: View {
}
return state.aiSession.transcript
case .generating:
+ if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
+ return ExtL10n.string("keyboard.ai.skill.extracting")
+ }
+ if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractEventsID {
+ return ExtL10n.string("keyboard.ai.skill.extractingEvents")
+ }
+ 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")
}
diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift
index 3c59241..0dd8362 100644
--- a/OSGKeyboardExt/Views/KeyboardRootView.swift
+++ b/OSGKeyboardExt/Views/KeyboardRootView.swift
@@ -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()
diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings
index 39367d3..94a3cb1 100644
--- a/OSGKeyboardExt/en.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/en.lproj/Keyboard.strings
@@ -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";
@@ -297,3 +305,23 @@
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.summarize" = "Summarize";
"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";
+"keyboard.ai.skill.previewTone" = "Tone";
+"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";
diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
index b16546a..6f00205 100644
--- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
@@ -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" = "支持本地转写";
@@ -297,3 +305,23 @@
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.summarize" = "总结";
"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" = "点子";
+"keyboard.ai.skill.previewTone" = "语气";
+"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 运行捷径";
diff --git a/OSGKeyboardExtTests/EnglishTypingTests.swift b/OSGKeyboardExtTests/EnglishTypingTests.swift
index 6f86fba..fbb9c2c 100644
--- a/OSGKeyboardExtTests/EnglishTypingTests.swift
+++ b/OSGKeyboardExtTests/EnglishTypingTests.swift
@@ -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
+ )
+ }
}
diff --git a/OSGKeyboardExtTests/KeyboardStateTests.swift b/OSGKeyboardExtTests/KeyboardStateTests.swift
index 5fd8ff4..54428a5 100644
--- a/OSGKeyboardExtTests/KeyboardStateTests.swift
+++ b/OSGKeyboardExtTests/KeyboardStateTests.swift
@@ -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")
+ }
}
diff --git a/OSGKeyboardExtTests/TypingTouchTrackerTests.swift b/OSGKeyboardExtTests/TypingTouchTrackerTests.swift
new file mode 100644
index 0000000..7b04f1e
--- /dev/null
+++ b/OSGKeyboardExtTests/TypingTouchTrackerTests.swift
@@ -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)
+ }
+}
diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift
index fb50bd4..d451504 100644
--- a/OSGKeyboardMac/MacDictationViewModel.swift
+++ b/OSGKeyboardMac/MacDictationViewModel.swift
@@ -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"
}
}
}
diff --git a/OSGKeyboardMac/MacPolishStylesView.swift b/OSGKeyboardMac/MacPolishStylesView.swift
index 33e34bb..71c196c 100644
--- a/OSGKeyboardMac/MacPolishStylesView.swift
+++ b/OSGKeyboardMac/MacPolishStylesView.swift
@@ -246,28 +246,22 @@ private struct MacPolishStyleCard: View {
}
.buttonStyle(.plain)
- // Builtin: eye → view prompt; custom: pencil → edit.
+ // Pencil opens the prompt (built-in, read-only) or the editor (custom).
Button(action: primaryAction) {
- Image(systemName: isUserStyle ? "pencil" : "eye")
- .font(.system(size: 12, weight: .semibold))
+ Image(systemName: "pencil")
+ .font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.textSecondary)
- .frame(width: 28, height: 28)
+ .frame(width: 30, height: 30)
.background(palette.background.opacity(isHovering ? 0.9 : 0.72), in: Circle())
}
.padding(Spacing.sm)
.buttonStyle(.plain)
- .accessibilityLabel(
- MacL10n.string(
- isUserStyle ? "mac.styles.edit" : "mac.styles.viewPrompt",
- language: language
- )
- )
+ .accessibilityLabel(MacL10n.string("mac.styles.edit", language: language))
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(palette.accent)
- .background(palette.surface, in: Circle())
.padding(Spacing.sm)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.allowsHitTesting(false)
diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift
index 23a9031..f8590f7 100644
--- a/OSGKeyboardMac/MacRootView.swift
+++ b/OSGKeyboardMac/MacRootView.swift
@@ -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)
diff --git a/OSGKeyboardShared/Models/AIAgentSkillLayout.swift b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
new file mode 100644
index 0000000..a242804
--- /dev/null
+++ b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
@@ -0,0 +1,80 @@
+// AIAgentSkillLayout.swift
+// OSGKeyboard · Shared
+//
+// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
+// the user confirmed a companion Shortcut for. Missing storage hydrates
+// to the three default transform skills; an explicit empty list is kept
+// so turning every skill off is distinct from a fresh install.
+
+import Foundation
+
+public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
+ public static let maximumEnabled = 8
+ public static let defaultEnabledIDs = [
+ AIClipboardSkillCatalog.replyID,
+ AIClipboardSkillCatalog.summarizeID,
+ AIClipboardSkillCatalog.translateID,
+ ]
+
+ /// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
+ public var enabledIDs: [String]
+ /// Export skills whose companion Shortcut the user marked as added.
+ public var confirmedShortcutIDs: [String]
+
+ public static let `default` = AIAgentSkillLayout(
+ enabledIDs: defaultEnabledIDs,
+ confirmedShortcutIDs: []
+ )
+
+ public init(enabledIDs: [String], confirmedShortcutIDs: [String]) {
+ self.enabledIDs = enabledIDs
+ self.confirmedShortcutIDs = confirmedShortcutIDs
+ }
+
+ public var isFull: Bool {
+ enabledIDs.count >= Self.maximumEnabled
+ }
+
+ public func isEnabled(_ id: String) -> Bool {
+ enabledIDs.contains(id)
+ }
+
+ public func hasConfirmedShortcut(_ id: String) -> Bool {
+ confirmedShortcutIDs.contains(id)
+ }
+
+ /// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
+ public func sanitized(
+ catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
+ ) -> AIAgentSkillLayout {
+ let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
+ var seenEnabled = Set()
+ let enabled = enabledIDs.filter { id in
+ guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
+ if skill.requiresShortcut {
+ return confirmedShortcutIDs.contains(id)
+ }
+ return true
+ }
+ .prefix(Self.maximumEnabled)
+
+ var seenConfirmed = Set()
+ let confirmed = confirmedShortcutIDs.filter { id in
+ guard known[id]?.requiresShortcut == true else { return false }
+ return seenConfirmed.insert(id).inserted
+ }
+
+ return AIAgentSkillLayout(
+ enabledIDs: Array(enabled),
+ confirmedShortcutIDs: confirmed
+ )
+ }
+}
+
+public enum AIAgentSkillEnableResult: Equatable, Sendable {
+ case enabled
+ case alreadyEnabled
+ case needsShortcut
+ case full
+ case unknown
+}
diff --git a/OSGKeyboardShared/Models/AIUserSkill.swift b/OSGKeyboardShared/Models/AIUserSkill.swift
new file mode 100644
index 0000000..0562542
--- /dev/null
+++ b/OSGKeyboardShared/Models/AIUserSkill.swift
@@ -0,0 +1,177 @@
+// AIUserSkill.swift
+// OSGKeyboard · Shared
+//
+// User-created clipboard skills. Built-in skills stay in
+// `AIClipboardSkillCatalog`; this catalog is persisted in App Group so the
+// keyboard can resolve custom names, prompts, and Shortcut run names.
+
+import Foundation
+
+public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public var name: String
+ /// Card subtitle. Empty is allowed; the UI shows a generic fallback.
+ public var summary: String
+ public var systemImage: String
+ public var prompt: String
+ public var shortcutICloudURL: URL
+ /// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
+ public var shortcutName: String
+ /// Per-skill reasoning. Built-in skills are always off; custom defaults off.
+ public var thinkingEnabled: Bool
+ public let createdAt: Date
+ public var updatedAt: Date
+
+ public init(
+ id: String = "user.\(UUID().uuidString.lowercased())",
+ name: String,
+ summary: String = "",
+ systemImage: String = AIUserSkillLimits.defaultSystemImage,
+ prompt: String,
+ shortcutICloudURL: URL,
+ shortcutName: String,
+ thinkingEnabled: Bool = false,
+ createdAt: Date = Date(),
+ updatedAt: Date? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.summary = summary
+ self.systemImage = systemImage
+ self.prompt = prompt
+ self.shortcutICloudURL = shortcutICloudURL
+ self.shortcutName = shortcutName
+ self.thinkingEnabled = thinkingEnabled
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt ?? createdAt
+ }
+
+ public var isUserCreated: Bool { id.hasPrefix("user.") }
+
+ public func asClipboardSkill() -> AIClipboardSkill {
+ AIClipboardSkill(
+ id: id,
+ systemImage: systemImage,
+ titleKey: "",
+ cardTitleKey: "",
+ descriptionKey: "",
+ kind: .export,
+ isDefault: false,
+ shortcutName: shortcutName,
+ shortcutICloudURL: shortcutICloudURL,
+ customName: name,
+ customSummary: summary,
+ customPrompt: prompt,
+ thinkingEnabled: thinkingEnabled
+ )
+ }
+}
+
+public enum AIUserSkillLimits {
+ public static let defaultSystemImage = "sparkles"
+ public static let maximumPromptCharacters = 6_000
+ public static let maximumNameCharacters = 40
+ public static let maximumSummaryCharacters = 200
+ public static let newPromptTemplate = """
+ 请根据剪贴板内容完成以下操作。
+ 只输出结果,不要解释或客套。
+ """
+
+ /// Curated SF Symbols for the skill-icon picker.
+ public static let symbolChoices: [String] = [
+ "sparkles",
+ "wand.and.stars",
+ "text.badge.checkmark",
+ "checklist",
+ "calendar",
+ "envelope.fill",
+ "bubble.left.and.bubble.right.fill",
+ "character.bubble.fill",
+ "doc.text.magnifyingglass",
+ "arrowshape.turn.up.left.fill",
+ "lightbulb.fill",
+ "star.fill",
+ "heart.fill",
+ "flag.fill",
+ "tag.fill",
+ "folder.fill",
+ "list.bullet",
+ "square.and.pencil",
+ "scissors",
+ "globe",
+ "paperplane.fill",
+ "clock.fill",
+ "bell.fill",
+ "bookmark.fill",
+ "person.fill",
+ "link",
+ "number",
+ "at",
+ "tray.fill",
+ "quote.bubble.fill",
+ ]
+}
+
+public enum AIUserSkillValidationError: Error, Equatable, Sendable {
+ case emptyName
+ case emptyPrompt
+ case emptyShortcutName
+ case invalidShortcutLink
+ case emptyIcon
+ case promptTooLong(maximum: Int)
+}
+
+public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
+ public var entries: [AIUserSkill]
+
+ public init(entries: [AIUserSkill] = []) {
+ self.entries = entries.filter(\.isUserCreated)
+ }
+
+ public static let empty = AIUserSkillCatalog()
+
+ public func skill(id: String) -> AIUserSkill? {
+ entries.first { $0.id == id }
+ }
+
+ public mutating func upsert(_ skill: AIUserSkill, at date: Date = Date()) throws {
+ let name = skill.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let summary = skill.summary.trimmingCharacters(in: .whitespacesAndNewlines)
+ let prompt = skill.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
+ let shortcutName = skill.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let icon = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard skill.isUserCreated else { throw AIUserSkillValidationError.emptyName }
+ guard !name.isEmpty else { throw AIUserSkillValidationError.emptyName }
+ guard !prompt.isEmpty else { throw AIUserSkillValidationError.emptyPrompt }
+ guard prompt.count <= AIUserSkillLimits.maximumPromptCharacters else {
+ throw AIUserSkillValidationError.promptTooLong(
+ maximum: AIUserSkillLimits.maximumPromptCharacters
+ )
+ }
+ guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
+ guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
+ throw AIUserSkillValidationError.invalidShortcutLink
+ }
+ guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
+
+ var saved = skill
+ saved.name = String(name.prefix(AIUserSkillLimits.maximumNameCharacters))
+ saved.summary = String(summary.prefix(AIUserSkillLimits.maximumSummaryCharacters))
+ saved.prompt = prompt
+ saved.shortcutName = shortcutName
+ saved.systemImage = icon
+ saved.thinkingEnabled = skill.thinkingEnabled
+ saved.updatedAt = date
+
+ if let index = entries.firstIndex(where: { $0.id == skill.id }) {
+ entries[index] = saved
+ } else {
+ entries.append(saved)
+ }
+ }
+
+ public mutating func remove(id: String) {
+ entries.removeAll { $0.id == id }
+ }
+}
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 5e63e66..19c858f 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -69,6 +69,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
/// Diagnostic switch: when false, local ASR skips the custom language model.
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
+ /// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
+ public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
+ /// User-created clipboard skills (no cloud sync; App Group only).
+ public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
}
// MARK: - Stored fields
@@ -113,8 +117,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
- /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
- public var localASRCustomLanguageModelEnabled: Bool
+ /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
+ public var localASRCustomLanguageModelEnabled: Bool
// MARK: - Derived
diff --git a/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift b/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift
index f0c3c4e..f58ae86 100644
--- a/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift
+++ b/OSGKeyboardShared/Models/FlowSession/FlowCommand.swift
@@ -41,6 +41,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
+ /// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
+ public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
public let startDeadlineAt: TimeInterval?
public let processingDeadlineAt: TimeInterval?
@@ -60,6 +62,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
+ aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
) {
@@ -77,6 +80,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
+ self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
}
diff --git a/OSGKeyboardShared/Models/FlowUtteranceRequest.swift b/OSGKeyboardShared/Models/FlowUtteranceRequest.swift
index b4ee119..be5e79a 100644
--- a/OSGKeyboardShared/Models/FlowUtteranceRequest.swift
+++ b/OSGKeyboardShared/Models/FlowUtteranceRequest.swift
@@ -13,6 +13,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
+ /// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
+ public let aiThinkingEnabled: Bool?
public static let dictation = FlowUtteranceRequest(mode: .dictation)
@@ -22,7 +24,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
sourceHistoryEntryID: UUID? = nil,
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
- aiQuestionText: String? = nil
+ aiQuestionText: String? = nil,
+ aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
self.editSourceText = editSourceText
@@ -30,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
+ self.aiThinkingEnabled = aiThinkingEnabled
}
public static func editLastInput(
@@ -48,12 +52,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public static func aiQuestion(
conversationID: UUID,
- prefilledQuestion: String? = nil
+ prefilledQuestion: String? = nil,
+ thinkingEnabled: Bool? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID,
- aiQuestionText: prefilledQuestion
+ aiQuestionText: prefilledQuestion,
+ aiThinkingEnabled: thinkingEnabled
)
}
}
diff --git a/OSGKeyboardShared/Services/AIAddressExtraction.swift b/OSGKeyboardShared/Services/AIAddressExtraction.swift
new file mode 100644
index 0000000..7d10f17
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAddressExtraction.swift
@@ -0,0 +1,148 @@
+// AIAddressExtraction.swift
+// OSGKeyboard · Shared
+//
+// Parses the LLM's navigate-skill reply into one origin|destination line.
+// Fail closed: empty / NONE / no destination never become a run. Only the
+// first valid line is kept — opening several map apps is not useful.
+
+import Foundation
+
+public struct AIMapRoute: Equatable, Sendable {
+ /// Nil means "current location" in the map app.
+ public let origin: String?
+ public let destination: String
+
+ public init(origin: String?, destination: String) {
+ self.origin = origin
+ self.destination = destination
+ }
+}
+
+public enum AIAddressExtraction: Sendable {
+ /// Canonical line: origin|destination. Empty origin keeps the pipe.
+ public static let fieldSeparator: Character = "|"
+
+ private static let emptyTokens: Set = [
+ "none", "no", "n/a", "na", "nil", "null",
+ "无", "没有", "没有地址", "没有地点", "无地址", "无地点",
+ "没有可导航的地点", "没有可导航的地址",
+ "no address", "no addresses", "no location", "no locations",
+ "no place", "no places", "no destination",
+ ]
+
+ /// Lines to send to the host. Empty → do not run the companion Shortcut.
+ public static func lines(
+ from raw: String,
+ sourceClipboard: String? = nil
+ ) -> [String] {
+ guard let route = route(from: raw, sourceClipboard: sourceClipboard) else {
+ return []
+ }
+ return [encode(route)]
+ }
+
+ public static func route(
+ from raw: String,
+ sourceClipboard: String? = nil
+ ) -> AIMapRoute? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ if isEmptyToken(trimmed) { return nil }
+
+ for line in trimmed.components(separatedBy: .newlines) {
+ guard let route = parseLine(line) else { continue }
+ if isWholeClipboardEcho(route, source: sourceClipboard) {
+ return nil
+ }
+ return route
+ }
+ return nil
+ }
+
+ public static func encode(_ route: AIMapRoute) -> String {
+ "\(route.origin ?? "")|\(route.destination)"
+ }
+
+ // MARK: - Line
+
+ private static func parseLine(_ line: String) -> AIMapRoute? {
+ let stripped = stripBullet(line)
+ guard !stripped.isEmpty, !isEmptyToken(stripped) else { return nil }
+
+ let parts = stripped
+ .split(separator: fieldSeparator, omittingEmptySubsequences: false)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+
+ let originRaw: String
+ let destinationRaw: String
+ if parts.count == 1 {
+ originRaw = ""
+ destinationRaw = parts[0]
+ } else {
+ originRaw = parts[0]
+ var destParts = Array(parts[1...])
+ while destParts.last?.isEmpty == true {
+ destParts.removeLast()
+ }
+ destinationRaw = destParts
+ .joined(separator: String(fieldSeparator))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ guard let destination = sanitizedPlace(destinationRaw) else { return nil }
+ let origin = sanitizedPlace(originRaw)
+ if let origin, origin.caseInsensitiveCompare(destination) == .orderedSame {
+ return AIMapRoute(origin: nil, destination: destination)
+ }
+ return AIMapRoute(origin: origin, destination: destination)
+ }
+
+ /// Drop URLs and empty / NONE tokens. Place names stay as-is.
+ private static func sanitizedPlace(_ raw: String) -> String? {
+ let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty, !isEmptyToken(text) else { return nil }
+ if text.contains("://") { return nil }
+ return text
+ }
+
+ 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
+ }
+
+ /// One long destination that is essentially the clipboard body is not a place.
+ private static func isWholeClipboardEcho(_ route: AIMapRoute, source: String?) -> Bool {
+ guard let source, source.count > 80, route.destination.count > 80 else {
+ return false
+ }
+ let a = collapse(route.destination)
+ 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()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
new file mode 100644
index 0000000..0e5b556
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
@@ -0,0 +1,143 @@
+// AIAgentShortcutRun.swift
+// OSGKeyboard · Shared
+//
+// Ephemeral App Group payload for one keyboard → host → Shortcuts hop.
+// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
+// The host consumes the payload (once) and opens the Shortcuts URL.
+
+import Foundation
+
+public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
+ public let skillID: String
+ public let titles: [String]
+ public let createdAt: Date
+
+ public init(skillID: String, titles: [String], createdAt: Date = Date()) {
+ self.skillID = skillID
+ self.titles = titles
+ self.createdAt = createdAt
+ }
+
+ public var joinedTitles: String {
+ titles.joined(separator: "\n")
+ }
+}
+
+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
+
+ public static func shortcutsRunURL(
+ name: String,
+ text: String,
+ xSuccess: String? = nil,
+ xError: String? = nil,
+ xCancel: String? = nil
+ ) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
+ if usesCallback {
+ components.host = "x-callback-url"
+ components.path = "/run-shortcut"
+ } else {
+ components.host = "run-shortcut"
+ }
+ var items = [
+ URLQueryItem(name: "name", value: name),
+ URLQueryItem(name: "input", value: "text"),
+ URLQueryItem(name: "text", value: text),
+ ]
+ if let xSuccess {
+ items.append(URLQueryItem(name: "x-success", value: xSuccess))
+ }
+ if let xError {
+ items.append(URLQueryItem(name: "x-error", value: xError))
+ }
+ if let xCancel {
+ items.append(URLQueryItem(name: "x-cancel", value: xCancel))
+ }
+ components.queryItems = items
+ return components.url
+ }
+
+ /// 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).
+ public static func preview(_ text: String, limit: Int = 1200) -> String {
+ let escaped = text
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\r\n", with: "\\n")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\n")
+ .replacingOccurrences(of: "\t", with: "\\t")
+ if escaped.count <= limit { return escaped }
+ return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
+ }
+
+ public static func traceBody(_ label: String, _ text: String) {
+ #if DEBUG
+ trace("\(label) chars=\(text.count) body=\(preview(text))")
+ #else
+ trace("\(label) chars=\(text.count)")
+ #endif
+ }
+
+ /// Opens the Shortcuts Add sheet for an iCloud share token.
+ /// Do not `open` the HTTPS share page from the app — Universal Links
+ /// often land on Gallery and drop the token.
+ public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
+ guard let token = iCloudShareToken(from: shareURL) else { return nil }
+ return URL(string: "shortcuts://shortcuts/\(token)")
+ }
+
+ public static func iCloudShareToken(from shareURL: URL) -> String? {
+ guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
+ let parts = shareURL.path.split(separator: "/").map(String.init)
+ guard let index = parts.firstIndex(of: "shortcuts"),
+ parts.count > index + 1 else { return nil }
+ let token = parts[index + 1]
+ guard token != "api", !token.isEmpty else { return nil }
+ return token
+ }
+
+ public static func openShortcutURL(name: String) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ components.host = "open-shortcut"
+ components.queryItems = [URLQueryItem(name: "name", value: name)]
+ return components.url
+ }
+
+ public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
+ try? JSONEncoder().encode(payload)
+ }
+
+ public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
+ return nil
+ }
+ guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
+ guard !payload.titles.isEmpty else { return nil }
+ return payload
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
new file mode 100644
index 0000000..32b9a2b
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
@@ -0,0 +1,175 @@
+// AIAgentSkillLayoutStore.swift
+// OSGKeyboard · Shared
+//
+// Observable facade over the persisted skill layout. The Skills tab mutates
+// this; the keyboard reads the same App Group snapshot on each config poll.
+
+import Foundation
+import Combine
+
+@MainActor
+public final class AIAgentSkillLayoutStore: ObservableObject {
+ public static let shared = AIAgentSkillLayoutStore()
+
+ @Published public private(set) var layout: AIAgentSkillLayout
+ @Published public private(set) var userCatalog: AIUserSkillCatalog
+
+ private let persistLayout: (AIAgentSkillLayout) -> Void
+ private let persistUserCatalog: (AIUserSkillCatalog) -> Void
+ private let loadLayout: () -> AIAgentSkillLayout
+ private let loadUserCatalog: () -> AIUserSkillCatalog
+
+ public init(defaults: UserDefaults? = nil) {
+ if let defaults {
+ self.loadUserCatalog = { AppGroupStore(defaults: defaults).agentUserSkillCatalog }
+ self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
+ self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
+ self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
+ } else {
+ self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
+ self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
+ self.loadLayout = { AppGroupStore().agentSkillLayout }
+ self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
+ }
+ self.userCatalog = self.loadUserCatalog()
+ self.layout = self.loadLayout()
+ }
+
+ public func reload() {
+ userCatalog = loadUserCatalog()
+ layout = loadLayout()
+ }
+
+ public var mergedCatalog: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.all(userCatalog: userCatalog)
+ }
+
+ public var enabledSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.visible(
+ enabledIDs: layout.enabledIDs,
+ userCatalog: userCatalog
+ )
+ }
+
+ public var availableSkills: [AIClipboardSkill] {
+ mergedCatalog.filter { !layout.isEnabled($0.id) }
+ }
+
+ public func userSkill(id: String) -> AIUserSkill? {
+ userCatalog.skill(id: id)
+ }
+
+ @discardableResult
+ public func enable(_ id: String) -> AIAgentSkillEnableResult {
+ let current = layout.sanitized(catalog: mergedCatalog)
+ guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
+ return .unknown
+ }
+ if current.isEnabled(id) { return .alreadyEnabled }
+ if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
+ return .needsShortcut
+ }
+ if current.isFull { return .full }
+ commitLayout(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs + [id],
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ return .enabled
+ }
+
+ /// Drops the keyboard slot only. Companion Shortcuts stay installed;
+ /// the user deletes them in the Shortcuts app if they want them gone.
+ public func disable(_ id: String) {
+ let current = layout.sanitized(catalog: mergedCatalog)
+ commitLayout(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs.filter { $0 != id },
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ }
+
+ /// Marks the companion Shortcut as added, then tries to occupy a slot.
+ @discardableResult
+ public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
+ guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
+ skill.requiresShortcut else {
+ return .unknown
+ }
+ var current = layout.sanitized(catalog: mergedCatalog)
+ if !current.confirmedShortcutIDs.contains(id) {
+ current.confirmedShortcutIDs.append(id)
+ }
+ commitLayout(current)
+ return enable(id)
+ }
+
+ public func moveEnabled(id draggedID: String, onto targetID: String) {
+ let ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
+ guard let to = ids.firstIndex(of: targetID) else { return }
+ moveEnabled(id: draggedID, toIndex: to)
+ }
+
+ public func moveEnabled(id draggedID: String, toIndex: Int) {
+ var ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
+ guard let from = ids.firstIndex(of: draggedID), !ids.isEmpty else { return }
+ let to = min(max(toIndex, 0), ids.count - 1)
+ guard from != to else { return }
+ ids.move(
+ fromOffsets: IndexSet(integer: from),
+ toOffset: to > from ? to + 1 : to
+ )
+ commitLayout(
+ AIAgentSkillLayout(
+ enabledIDs: ids,
+ confirmedShortcutIDs: layout.confirmedShortcutIDs
+ )
+ )
+ }
+
+ public func saveUserSkill(_ skill: AIUserSkill) throws {
+ let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
+ var catalog = userCatalog
+ try catalog.upsert(skill)
+ commitUserCatalog(catalog)
+ if previousURL != nil, previousURL != skill.shortcutICloudURL {
+ dropShortcutConfirmation(for: skill.id)
+ }
+ }
+
+ public func deleteUserSkill(id: String) {
+ var catalog = userCatalog
+ catalog.remove(id: id)
+ commitUserCatalog(catalog)
+ let current = layout.sanitized(catalog: mergedCatalog)
+ commitLayout(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs.filter { $0 != id },
+ confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
+ )
+ )
+ }
+
+ private func dropShortcutConfirmation(for id: String) {
+ let current = layout.sanitized(catalog: mergedCatalog)
+ commitLayout(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs.filter { $0 != id },
+ confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
+ )
+ )
+ }
+
+ private func commitLayout(_ layout: AIAgentSkillLayout) {
+ persistLayout(layout)
+ self.layout = loadLayout()
+ }
+
+ private func commitUserCatalog(_ catalog: AIUserSkillCatalog) {
+ persistUserCatalog(catalog)
+ userCatalog = loadUserCatalog()
+ layout = loadLayout()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift
index f7d0b56..1ddb5fb 100644
--- a/OSGKeyboardShared/Services/AIClipboardSkill.swift
+++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift
@@ -2,20 +2,78 @@
// OSGKeyboard · Shared
//
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
-// Settings can later persist a subset or permutation without changing the view.
+// 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 the host after the model runs (Shortcut, Maps, or Didi).
import Foundation
+public enum AIClipboardSkillKind: String, Sendable {
+ /// LLM output is reviewed and inserted into the current text field.
+ case transform
+ /// LLM output is parsed and sent to a companion Shortcut. Never inserted.
+ case export
+}
+
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
- /// Keyboard.strings key for the short button title.
+ /// Keyboard.strings key for the short chip title.
public let titleKey: String
+ /// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
+ public let cardTitleKey: String
+ public let descriptionKey: String
+ public let kind: AIClipboardSkillKind
+ /// Default skills can be turned off but not removed from the catalog.
+ public let isDefault: Bool
+ /// Frozen companion Shortcut name. Nil for transform skills.
+ public let shortcutName: String?
+ /// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
+ public let shortcutICloudURL: URL?
+ /// Bundled `.shortcut` resource name without extension. Nil → no file fallback.
+ public let shortcutResourceName: String?
+ /// User-created skills store display copy here instead of localization keys.
+ public let customName: String?
+ public let customSummary: String?
+ public let customPrompt: String?
+ /// Built-in skills are always false. Custom skills default off.
+ public let thinkingEnabled: Bool
- public init(id: String, systemImage: String, titleKey: String) {
+ /// 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(
+ id: String,
+ systemImage: String,
+ titleKey: String,
+ cardTitleKey: String,
+ descriptionKey: String,
+ kind: AIClipboardSkillKind,
+ isDefault: Bool,
+ shortcutName: String? = nil,
+ shortcutICloudURL: URL? = nil,
+ shortcutResourceName: String? = nil,
+ customName: String? = nil,
+ customSummary: String? = nil,
+ customPrompt: String? = nil,
+ thinkingEnabled: Bool = false
+ ) {
self.id = id
self.systemImage = systemImage
self.titleKey = titleKey
+ self.cardTitleKey = cardTitleKey
+ self.descriptionKey = descriptionKey
+ self.kind = kind
+ self.isDefault = isDefault
+ self.shortcutName = shortcutName
+ self.shortcutICloudURL = shortcutICloudURL
+ self.shortcutResourceName = shortcutResourceName
+ self.customName = customName
+ self.customSummary = customSummary
+ self.customPrompt = customPrompt
+ self.thinkingEnabled = id.hasPrefix("user.") ? thinkingEnabled : false
}
}
@@ -23,42 +81,135 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let replyID = "reply"
public static let summarizeID = "summarize"
public static let translateID = "translate"
+ public static let extractTodosID = "extractTodos"
+ public static let extractTodosShortcutName = "OSGExtractTodos"
+ public static let extractTodosResourceName = "OSGExtractTodos"
- /// Default set, in display order. Future skills append here.
- public static let builtIn: [AIClipboardSkill] = [
+ public static let extractEventsID = "extractEvents"
+ 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"
+
+ /// Full built-in catalog, in a stable display order for the Skills tab.
+ public static let catalog: [AIClipboardSkill] = [
AIClipboardSkill(
id: replyID,
systemImage: "arrowshape.turn.up.left.fill",
- titleKey: "keyboard.ai.skill.reply"
+ titleKey: "keyboard.ai.skill.reply",
+ cardTitleKey: "skills.reply.name",
+ descriptionKey: "skills.reply.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: summarizeID,
systemImage: "doc.text.magnifyingglass",
- titleKey: "keyboard.ai.skill.summarize"
+ titleKey: "keyboard.ai.skill.summarize",
+ cardTitleKey: "skills.summarize.name",
+ descriptionKey: "skills.summarize.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: translateID,
systemImage: "character.bubble.fill",
- titleKey: "keyboard.ai.skill.translate"
+ titleKey: "keyboard.ai.skill.translate",
+ cardTitleKey: "skills.translate.name",
+ descriptionKey: "skills.translate.description",
+ kind: .transform,
+ isDefault: true
+ ),
+ AIClipboardSkill(
+ id: extractTodosID,
+ systemImage: "checklist",
+ titleKey: "keyboard.ai.skill.extractTodos",
+ cardTitleKey: "skills.extractTodos.name",
+ descriptionKey: "skills.extractTodos.description",
+ kind: .export,
+ isDefault: false,
+ shortcutName: extractTodosShortcutName,
+ shortcutResourceName: extractTodosResourceName
+ ),
+ AIClipboardSkill(
+ id: extractEventsID,
+ systemImage: "calendar",
+ titleKey: "keyboard.ai.skill.extractEvents",
+ cardTitleKey: "skills.extractEvents.name",
+ descriptionKey: "skills.extractEvents.description",
+ kind: .export,
+ isDefault: false,
+ shortcutName: extractEventsShortcutName,
+ 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",
+ titleKey: "keyboard.ai.skill.navigate",
+ cardTitleKey: "skills.navigate.name",
+ descriptionKey: "skills.navigate.description",
+ kind: .export,
+ isDefault: false
),
]
- /// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
- public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
- guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
- let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
- return enabledIDs.compactMap { byID[$0] }
+ /// Legacy alias: the three default transform skills used to be the whole list.
+ public static let builtIn: [AIClipboardSkill] = catalog
+
+ public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
+ catalog + userCatalog.entries.map { $0.asClipboardSkill() }
+ }
+
+ public static func skill(
+ id: String,
+ userCatalog: AIUserSkillCatalog = .empty
+ ) -> AIClipboardSkill? {
+ catalog.first { $0.id == id } ?? userCatalog.skill(id: id)?.asClipboardSkill()
+ }
+
+ /// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
+ /// An explicit empty array shows no chips (carousel fallback).
+ public static func visible(
+ enabledIDs: [String]? = nil,
+ userCatalog: AIUserSkillCatalog = .empty
+ ) -> [AIClipboardSkill] {
+ let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
+ guard !ids.isEmpty else { return [] }
+ let byID = Dictionary(uniqueKeysWithValues: all(userCatalog: userCatalog).map { ($0.id, $0) })
+ return ids.compactMap { byID[$0] }
}
public static func instruction(
for skill: AIClipboardSkill,
locale: String,
- translationTargetLocaleId: String
+ translationTargetLocaleId: String,
+ now: Date = Date()
) -> String {
- instruction(
+ if let custom = skill.customPrompt?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !custom.isEmpty {
+ return custom
+ }
+ return instruction(
skillID: skill.id,
locale: locale,
- translationTargetLocaleId: translationTargetLocaleId
+ translationTargetLocaleId: translationTargetLocaleId,
+ now: now
)
}
@@ -85,7 +236,8 @@ public enum AIClipboardSkillCatalog: Sendable {
public static func instruction(
skillID: String,
locale: String,
- translationTargetLocaleId: String
+ translationTargetLocaleId: String,
+ now: Date = Date()
) -> String {
let zh = locale == "zh"
switch skillID {
@@ -102,6 +254,24 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
)
+ case extractTodosID:
+ return zh
+ ? """
+ 请从剪贴板中只提取明确的待办事项。每条一行,只要标题,不要编号、不要项目符号、不要解释。最多 20 条。
+ 若没有任何可执行的待办,只输出 NONE,不要把整段原文当成一条待办。
+ 若原文本身就是一句短待办(例如「买牛奶」),输出那一句即可。
+ """
+ : """
+ Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
+ If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
+ If the clipboard itself is already one short task (for example "buy milk"), output that single line.
+ """
+ case extractEventsID:
+ return eventInstruction(zh: zh, now: now)
+ case saveToNotesID:
+ return noteInstruction(zh: zh, now: now)
+ case navigateID:
+ return navigateInstruction(zh: zh)
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
@@ -109,6 +279,81 @@ 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 """
+ 请从剪贴板提取明确的地点用于导航。只输出一行,两段用 | 分隔:起点|终点
+ 从当前位置出发则起点留空,但保留竖线,例如 |朝阳区酒仙桥路10号
+ 两点都写了则两侧都填,例如 北京南站|三里屯太古里
+ 可以是完整地址或常用地名。不要编号、不要解释、不要多行、不要链接。
+ 若有多条地址,只输出最明确的一条。
+ 没有可导航的地点时,只输出 NONE。不要把整段原文当成一个地点。
+ """
+ }
+ return """
+ Extract one place for turn-by-turn navigation from the clipboard. One line, two fields separated by | : origin|destination
+ Leave origin empty when starting from the current location, but keep the pipe, for example |10 Jiuxianqiao Road
+ Fill both sides when the source names two places, for example Beijing South|Sanlitun Taikoo Li
+ A full address or a well-known place name is fine. No numbering, commentary, extra lines, or URLs.
+ If there are several addresses, output only the clearest one.
+ If there is no navigable place, output NONE and nothing else. Do not treat the whole clipboard as one place.
+ """
+ }
+
+ /// Clock context so relative phrases (tomorrow, 3pm) resolve to local time.
+ private static func eventInstruction(zh: Bool, now: Date) -> String {
+ let clock = clockContext(now: now, zh: zh)
+ if zh {
+ return """
+ \(clock)
+ 请从剪贴板提取明确的日程。每条一行,四段用 | 分隔:开始|结束|标题|地点
+ 开始有钟点用 YYYY-MM-DD HH:mm;只有日期(全天)用 YYYY-MM-DD。没有结束时间或地点则该段留空,但保留竖线。标题中不要出现 |。最多 20 条。不要编号、不要解释。
+ 只有时刻、没有日期时,使用今天的日期。日期和时间都没有的条目不要输出。
+ 原文写了结束时间就填写结束段,否则留空(后续按 1 小时处理)。原文有地点就填写地点段。
+ 若没有任何带日期或时间的日程,只输出 NONE,不要把整段原文当成一条日程。
+ """
+ }
+ return """
+ \(clock)
+ Extract explicit calendar events from the clipboard. One event per line, four fields separated by | : start|end|title|location
+ Timed start uses YYYY-MM-DD HH:mm; date-only (all-day) uses YYYY-MM-DD. Leave end or location empty when unknown, but keep the pipes. Do not put | in the title. Maximum 20 lines. No numbering or commentary.
+ Time without a date uses today. Skip items that have neither a date nor a time.
+ Fill the end field when the source gives an end time; otherwise leave it empty (treated as 1 hour). Fill location when the source names a place.
+ If there are no events with a date or time, output NONE and nothing else. Do not treat the whole clipboard as one event.
+ """
+ }
+
+ private static func clockContext(now: Date, zh: Bool) -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: zh ? "zh_CN" : "en_US_POSIX")
+ formatter.timeZone = TimeZone.current
+ formatter.dateFormat = zh ? "yyyy年M月d日EEEE HH:mm" : "EEEE, d MMMM yyyy, HH:mm"
+ let stamp = formatter.string(from: now)
+ return zh
+ ? "现在是\(stamp)(设备本地时区)。"
+ : "It is now \(stamp) (device local timezone)."
+ }
+
/// Uses the keyboard translation target when set; otherwise Chinese ↔ English.
private static func translateInstruction(
locale: String,
diff --git a/OSGKeyboardShared/Services/AIEventExtraction.swift b/OSGKeyboardShared/Services/AIEventExtraction.swift
new file mode 100644
index 0000000..2613a6f
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIEventExtraction.swift
@@ -0,0 +1,279 @@
+// AIEventExtraction.swift
+// OSGKeyboard · Shared
+//
+// Parses the LLM's extract-events reply into Shortcut lines. Fail closed:
+// empty / NONE / lines without a parseable start never become a run.
+// Canonical line: start|end|title|location
+// All-day uses end token ALLDAY so the companion Shortcut can branch.
+
+import Foundation
+
+public enum AIEventExtraction: Sendable {
+ public static let maximumItems = 20
+ public static let defaultDuration: TimeInterval = 3600
+ /// End-field sentinel for all-day events (Shortcut If equals this).
+ public static let allDaySentinel = "ALLDAY"
+
+ private static let emptyTokens: Set = [
+ "none", "no", "n/a", "na", "nil", "null",
+ "无", "没有", "没有日程", "没有事件", "无日程",
+ "没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
+ "no events", "no event", "no calendar events",
+ "no date", "no time", "no date or time", "no date and time",
+ ]
+
+ /// Lines to send to the companion Shortcut. Empty → do not run it.
+ public static func lines(
+ from raw: String,
+ sourceClipboard: String? = nil,
+ now: Date = Date(),
+ calendar: Calendar = .current
+ ) -> [String] {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return [] }
+ if isEmptyToken(trimmed) { return [] }
+
+ var seen = Set()
+ var items: [String] = []
+ for line in trimmed.components(separatedBy: .newlines) {
+ guard let encoded = encodeLine(
+ line,
+ now: now,
+ calendar: calendar
+ ) else { continue }
+ let key = encoded.lowercased()
+ guard seen.insert(key).inserted else { continue }
+ items.append(encoded)
+ if items.count == maximumItems { break }
+ }
+
+ if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
+ return []
+ }
+ return items
+ }
+
+ // MARK: - Line
+
+ private static func encodeLine(
+ _ line: String,
+ now: Date,
+ calendar: Calendar
+ ) -> String? {
+ let fields = splitFields(line)
+ guard fields.count >= 2 else { return nil }
+
+ let startRaw = fields[0]
+ let endRaw: String
+ let titleRaw: String
+ let locationRaw: String
+ switch fields.count {
+ case 2:
+ endRaw = ""
+ titleRaw = fields[1]
+ locationRaw = ""
+ case 3:
+ // 3 fields: start|end|title if segment 2 is a time/empty;
+ // otherwise start|title|location.
+ if fields[1].isEmpty || parseInstant(fields[1], on: now, calendar: calendar) != nil {
+ endRaw = fields[1]
+ titleRaw = fields[2]
+ locationRaw = ""
+ } else {
+ endRaw = ""
+ titleRaw = fields[1]
+ locationRaw = fields[2]
+ }
+ default:
+ endRaw = fields[1]
+ titleRaw = fields[2]
+ locationRaw = fields[3]
+ }
+
+ let title = stripBullet(titleRaw)
+ guard !title.isEmpty, !isEmptyToken(title) else { return nil }
+ let location = locationRaw.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard let start = parseInstant(startRaw, on: now, calendar: calendar) else {
+ return nil
+ }
+
+ switch start {
+ case .allDay(let day):
+ return encode(
+ start: formatDay(day, calendar: calendar),
+ end: allDaySentinel,
+ title: title,
+ location: location
+ )
+ case .timed(let startDate, _):
+ let endDate = resolveEnd(
+ endRaw,
+ start: startDate,
+ calendar: calendar
+ )
+ return encode(
+ start: formatMinute(startDate, calendar: calendar),
+ end: formatMinute(endDate, calendar: calendar),
+ title: title,
+ location: location
+ )
+ }
+ }
+
+ /// Split on `|` and keep at most 4 fields; extra segments join into location.
+ private static func splitFields(_ line: String) -> [String] {
+ let parts = line
+ .split(separator: "|", omittingEmptySubsequences: false)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ guard parts.count > 4 else { return parts }
+ let location = parts[3...].joined(separator: "|")
+ return [parts[0], parts[1], parts[2], location]
+ }
+
+ private static func encode(start: String, end: String, title: String, location: String) -> String {
+ "\(start)|\(end)|\(title)|\(location)"
+ }
+
+ // MARK: - Time
+
+ private enum Instant {
+ case allDay(Date)
+ case timed(Date, timeOnly: Bool)
+ }
+
+ private static func parseInstant(
+ _ raw: String,
+ on day: Date,
+ calendar: Calendar
+ ) -> Instant? {
+ let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return nil }
+ if text.caseInsensitiveCompare(allDaySentinel) == .orderedSame {
+ return .allDay(calendar.startOfDay(for: day))
+ }
+
+ for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] {
+ if let date = date(from: text, format: format, calendar: calendar) {
+ return .timed(date, timeOnly: false)
+ }
+ }
+ if let date = date(from: text, format: "yyyy-MM-dd", calendar: calendar) {
+ return .allDay(calendar.startOfDay(for: date))
+ }
+ for format in ["HH:mm:ss", "HH:mm", "H:mm"] {
+ if let parsed = date(from: text, format: format, calendar: calendar) {
+ let parts = calendar.dateComponents([.hour, .minute, .second], from: parsed)
+ guard let combined = calendar.date(
+ bySettingHour: parts.hour ?? 0,
+ minute: parts.minute ?? 0,
+ second: 0,
+ of: day
+ ) else { return nil }
+ return .timed(combined, timeOnly: true)
+ }
+ }
+ return nil
+ }
+
+ private static func resolveEnd(
+ _ raw: String,
+ start: Date,
+ calendar: Calendar
+ ) -> Date {
+ let fallback = start.addingTimeInterval(defaultDuration)
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty,
+ trimmed.caseInsensitiveCompare(allDaySentinel) != .orderedSame else {
+ return fallback
+ }
+ guard let parsed = parseInstant(trimmed, on: start, calendar: calendar) else {
+ return fallback
+ }
+ let end: Date
+ switch parsed {
+ case .allDay(let day):
+ // Date-only end with a timed start: use that calendar day at 23:59.
+ end = calendar.date(bySettingHour: 23, minute: 59, second: 0, of: day) ?? fallback
+ case .timed(let date, let timeOnly):
+ if timeOnly, date <= start {
+ end = calendar.date(byAdding: .day, value: 1, to: date) ?? fallback
+ } else {
+ end = date
+ }
+ }
+ return end <= start ? fallback : end
+ }
+
+ private static func date(from text: String, format: String, calendar: Calendar) -> Date? {
+ let formatter = DateFormatter()
+ formatter.calendar = calendar
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = calendar.timeZone
+ formatter.isLenient = false
+ formatter.dateFormat = format
+ return formatter.date(from: text)
+ }
+
+ private static func formatDay(_ date: Date, calendar: Calendar) -> String {
+ format(date, "yyyy-MM-dd", calendar: calendar)
+ }
+
+ private static func formatMinute(_ date: Date, calendar: Calendar) -> String {
+ format(date, "yyyy-MM-dd HH:mm", calendar: calendar)
+ }
+
+ private static func format(_ date: Date, _ format: String, calendar: Calendar) -> String {
+ let formatter = DateFormatter()
+ formatter.calendar = calendar
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = calendar.timeZone
+ formatter.dateFormat = format
+ return formatter.string(from: date)
+ }
+
+ // MARK: - Tokens
+
+ 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
+ }
+
+ /// One long title that is essentially the clipboard body is not an event.
+ private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
+ guard let source, source.count > 80 else { return false }
+ let fields = item.split(separator: "|", omittingEmptySubsequences: false)
+ guard fields.count >= 3 else { return false }
+ let title = String(fields[2])
+ guard 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()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIGenericSkillExport.swift b/OSGKeyboardShared/Services/AIGenericSkillExport.swift
new file mode 100644
index 0000000..97b5c8d
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIGenericSkillExport.swift
@@ -0,0 +1,25 @@
+// AIGenericSkillExport.swift
+// OSGKeyboard · Shared
+//
+// Line-oriented Shortcut input for user-created export skills. Built-in
+// extract-todos / extract-events / save-to-notes keep their dedicated parsers.
+
+import Foundation
+
+public enum AIGenericSkillExport {
+ public static let maximumItems = 20
+
+ public static func items(from answer: String) -> [String] {
+ let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return [] }
+ if trimmed.compare("NONE", options: .caseInsensitive) == .orderedSame {
+ return []
+ }
+ let lines = trimmed
+ .split(whereSeparator: \.isNewline)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if lines.isEmpty { return [trimmed] }
+ return Array(lines.prefix(maximumItems))
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIMapNavigation.swift b/OSGKeyboardShared/Services/AIMapNavigation.swift
new file mode 100644
index 0000000..fac9c9a
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIMapNavigation.swift
@@ -0,0 +1,125 @@
+// AIMapNavigation.swift
+// OSGKeyboard · Shared
+//
+// Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu →
+// Apple Maps. The host calls `canOpenURL` and opens the URL itself.
+
+import Foundation
+
+public enum AIMapProvider: String, Equatable, Sendable {
+ case amap
+ case baidu
+ case apple
+}
+
+public enum AIMapNavigation: Sendable {
+ public static let sourceApplication = "OSGKeyboard"
+ public static let baiduSource = "ios.osgkeyboard"
+ /// Amap's usual current-location label when we have no coordinates.
+ public static let amapCurrentLocationName = "我的位置"
+
+ public static func provider(canOpen: (URL) -> Bool) -> AIMapProvider {
+ if canOpen(probe("iosamap")) || canOpen(probe("amapuri")) {
+ return .amap
+ }
+ if canOpen(probe("baidumap")) {
+ return .baidu
+ }
+ return .apple
+ }
+
+ public static func url(for route: AIMapRoute, canOpen: (URL) -> Bool) -> URL {
+ switch provider(canOpen: canOpen) {
+ case .amap:
+ if canOpen(probe("iosamap")) {
+ return amapURL(route, useLegacyScheme: true)
+ }
+ return amapURL(route, useLegacyScheme: false)
+ case .baidu:
+ return baiduURL(route)
+ case .apple:
+ return appleURL(route)
+ }
+ }
+
+ /// Keyboard payload is `origin|destination`. Host turns it into one URL.
+ public static func shortcutInput(
+ from encoded: String,
+ canOpen: (URL) -> Bool
+ ) -> String? {
+ guard let route = AIAddressExtraction.route(from: encoded) else { return nil }
+ return url(for: route, canOpen: canOpen).absoluteString
+ }
+
+ // MARK: - Providers
+
+ private static func amapURL(_ route: AIMapRoute, useLegacyScheme: Bool) -> URL {
+ let originName = route.origin ?? amapCurrentLocationName
+ let items = [
+ URLQueryItem(name: "sourceApplication", value: sourceApplication),
+ URLQueryItem(name: "sname", value: originName),
+ URLQueryItem(name: "dname", value: route.destination),
+ URLQueryItem(name: "dev", value: "0"),
+ URLQueryItem(name: "t", value: "0"),
+ ]
+ if useLegacyScheme {
+ return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
+ }
+ let url = makeURL(scheme: "amapuri", host: "route", path: "/plan/", items: items)
+ // URLComponents drops the trailing slash; Amap's documented path is `/plan/`.
+ let withSlash = url.absoluteString.replacingOccurrences(
+ of: "://route/plan?",
+ with: "://route/plan/?"
+ )
+ return URL(string: withSlash) ?? url
+ }
+
+ private static func baiduURL(_ route: AIMapRoute) -> URL {
+ var items = [
+ URLQueryItem(name: "destination", value: "name:\(route.destination)"),
+ URLQueryItem(name: "mode", value: "driving"),
+ URLQueryItem(name: "src", value: baiduSource),
+ ]
+ if let origin = route.origin {
+ items.insert(
+ URLQueryItem(name: "origin", value: "name:\(origin)"),
+ at: 0
+ )
+ }
+ return makeURL(scheme: "baidumap", host: "map", path: "/direction", items: items)
+ }
+
+ private static func appleURL(_ route: AIMapRoute) -> URL {
+ var items = [
+ URLQueryItem(name: "daddr", value: route.destination),
+ URLQueryItem(name: "dirflg", value: "d"),
+ ]
+ if let origin = route.origin {
+ items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
+ }
+ // `URLComponents(string: "maps://")` keeps the `://` that opening needs.
+ var components = URLComponents(string: "maps://")!
+ components.queryItems = items
+ return components.url ?? URL(string: "maps://")!
+ }
+
+ private static func probe(_ scheme: String) -> URL {
+ URL(string: "\(scheme)://")!
+ }
+
+ private static func makeURL(
+ scheme: String,
+ host: String?,
+ path: String?,
+ items: [URLQueryItem]
+ ) -> URL {
+ var components = URLComponents()
+ components.scheme = scheme
+ components.host = host
+ if let path {
+ components.path = path
+ }
+ components.queryItems = items
+ return components.url ?? URL(string: "\(scheme)://")!
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIModeLLMClientFactory.swift b/OSGKeyboardShared/Services/AIModeLLMClientFactory.swift
index 7e31d7b..6f582e2 100644
--- a/OSGKeyboardShared/Services/AIModeLLMClientFactory.swift
+++ b/OSGKeyboardShared/Services/AIModeLLMClientFactory.swift
@@ -8,14 +8,15 @@
import Foundation
public enum AIModeLLMClientFactory {
- /// Build an AI-mode client. Thinking is always forced on for this path.
- /// When `allowWebSearch` is false, returns the plain polish-compatible client.
+ /// Build an AI-mode client. Thinking defaults on for spoken questions.
+ /// Clipboard skills pass `thinkingEnabled` from the skill (built-in: off).
public static func make(
providerId: String,
baseURL: String,
apiKey: String,
model: String,
allowWebSearch: Bool = true,
+ thinkingEnabled: Bool = true,
session: URLSession = .shared
) -> any LLMClient {
let plain = LLMClientFactory.make(
@@ -23,10 +24,10 @@ public enum AIModeLLMClientFactory {
baseURL: baseURL,
apiKey: apiKey,
model: model,
- thinkingEnabled: true,
+ thinkingEnabled: thinkingEnabled,
session: session
)
- guard allowWebSearch else { return plain }
+ guard thinkingEnabled, allowWebSearch else { return plain }
guard let searching = makeSearchingClient(
providerId: providerId,
diff --git a/OSGKeyboardShared/Services/AINoteExport.swift b/OSGKeyboardShared/Services/AINoteExport.swift
new file mode 100644
index 0000000..bfe8366
--- /dev/null
+++ b/OSGKeyboardShared/Services/AINoteExport.swift
@@ -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 = [
+ "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[.. 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()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIQuestionService.swift b/OSGKeyboardShared/Services/AIQuestionService.swift
index 793979d..6a61c5d 100644
--- a/OSGKeyboardShared/Services/AIQuestionService.swift
+++ b/OSGKeyboardShared/Services/AIQuestionService.swift
@@ -123,7 +123,8 @@ public struct AIQuestionService: Sendable {
public static func configured(
store: any ConfigurationStore,
- conversations: AIConversationStore
+ conversations: AIConversationStore,
+ thinkingEnabled: Bool = true
) throws -> AIQuestionService {
// Same provider + baseURL + model resolution as dictation polish so the
// Settings LLM card is the single source of truth for both modes.
@@ -151,7 +152,8 @@ public struct AIQuestionService: Sendable {
baseURL: endpoint.baseURL,
apiKey: apiKey,
model: endpoint.model,
- allowWebSearch: true
+ allowWebSearch: true,
+ thinkingEnabled: thinkingEnabled
),
conversations: conversations,
responseLength: store.aiResponseLength
diff --git a/OSGKeyboardShared/Services/AIShortcutShareLink.swift b/OSGKeyboardShared/Services/AIShortcutShareLink.swift
new file mode 100644
index 0000000..3d967b6
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIShortcutShareLink.swift
@@ -0,0 +1,94 @@
+// AIShortcutShareLink.swift
+// OSGKeyboard · Shared
+//
+// Validates iCloud Shortcut share URLs and reads the published name from
+// Apple's undocumented records endpoint. Running a Shortcut still requires
+// that name (`shortcuts://run-shortcut?name=`); the share link is install-only.
+
+import Foundation
+
+public enum AIShortcutShareLink {
+ /// `https://www.icloud.com/shortcuts/{token}` or `https://icloud.com/shortcuts/{token}`.
+ public static func isValid(_ url: URL) -> Bool {
+ guard let token = AIAgentShortcutRun.iCloudShareToken(from: url) else { return false }
+ let allowed = CharacterSet.alphanumerics
+ return (16...64).contains(token.count)
+ && token.unicodeScalars.allSatisfy { allowed.contains($0) }
+ }
+
+ public static func parse(_ raw: String) -> URL? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let withScheme: String
+ if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
+ withScheme = trimmed
+ } else {
+ withScheme = "https://\(trimmed)"
+ }
+ guard let url = URL(string: withScheme), isValid(url) else { return nil }
+ return url
+ }
+}
+
+public enum AIShortcutShareMetadataError: Error, Equatable, Sendable {
+ case invalidLink
+ case network
+ case missingName
+}
+
+public enum AIShortcutShareMetadata {
+ public static func recordsURL(for shareURL: URL) -> URL? {
+ guard let token = AIAgentShortcutRun.iCloudShareToken(from: shareURL) else { return nil }
+ return URL(string: "https://www.icloud.com/shortcuts/api/records/\(token)")
+ }
+
+ /// Parses `fields.name.value`, or the older `records[0].fields.name.value`.
+ public static func name(fromRecordsJSON data: Data) throws -> String {
+ let object = try JSONSerialization.jsonObject(with: data)
+ guard let root = object as? [String: Any] else {
+ throw AIShortcutShareMetadataError.missingName
+ }
+ if let name = stringName(in: root) {
+ return name
+ }
+ if let records = root["records"] as? [[String: Any]],
+ let first = records.first,
+ let name = stringName(in: first) {
+ return name
+ }
+ throw AIShortcutShareMetadataError.missingName
+ }
+
+ public static func fetchName(
+ from shareURL: URL,
+ session: URLSession = .shared
+ ) async throws -> String {
+ guard let url = recordsURL(for: shareURL) else {
+ throw AIShortcutShareMetadataError.invalidLink
+ }
+ let data: Data
+ do {
+ let (bytes, response) = try await session.data(from: url)
+ guard let http = response as? HTTPURLResponse,
+ (200..<300).contains(http.statusCode) else {
+ throw AIShortcutShareMetadataError.network
+ }
+ data = bytes
+ } catch let error as AIShortcutShareMetadataError {
+ throw error
+ } catch {
+ throw AIShortcutShareMetadataError.network
+ }
+ return try name(fromRecordsJSON: data)
+ }
+
+ private static func stringName(in record: [String: Any]) -> String? {
+ guard let fields = record["fields"] as? [String: Any],
+ let nameField = fields["name"] as? [String: Any],
+ let value = nameField["value"] as? String else {
+ return nil
+ }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+}
diff --git a/OSGKeyboardShared/Services/AITodoExtraction.swift b/OSGKeyboardShared/Services/AITodoExtraction.swift
new file mode 100644
index 0000000..262dfc3
--- /dev/null
+++ b/OSGKeyboardShared/Services/AITodoExtraction.swift
@@ -0,0 +1,82 @@
+// AITodoExtraction.swift
+// OSGKeyboard · Shared
+//
+// Parses the LLM's extract-todos reply into reminder titles. Fail closed:
+// empty / NONE / "no tasks" never become a Shortcut run. A single long
+// echo of the clipboard is also rejected so the model cannot dump the
+// whole paste as one reminder.
+
+import Foundation
+
+public enum AITodoExtraction: Sendable {
+ public static let maximumItems = 20
+
+ private static let emptyTokens: Set = [
+ "none", "no", "n/a", "na", "nil", "null",
+ "无", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
+ "no tasks", "no task", "no todos", "no to-dos", "no to-do",
+ "no actionable items", "no action items",
+ ]
+
+ /// Titles to send to the companion Shortcut. Empty → do not run it.
+ public static func items(from raw: String, sourceClipboard: String? = nil) -> [String] {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return [] }
+ if isEmptyToken(trimmed) { return [] }
+
+ var seen = Set()
+ var items: [String] = []
+ for line in trimmed.components(separatedBy: .newlines) {
+ let title = stripBullet(line)
+ guard !title.isEmpty, !isEmptyToken(title) else { continue }
+ let key = title.lowercased()
+ guard seen.insert(key).inserted else { continue }
+ items.append(title)
+ if items.count == maximumItems { break }
+ }
+
+ if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
+ return []
+ }
+ return items
+ }
+
+ 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
+ }
+
+ /// One long line that is essentially the clipboard body is not a todo.
+ private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
+ guard let source, source.count > 80, item.count > 80 else { return false }
+ let a = collapse(item)
+ 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()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
index 0357a30..9cbc4d7 100644
--- a/OSGKeyboardShared/Services/AppGroupStore.swift
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -97,6 +97,14 @@ public struct AppGroupStore: @unchecked Sendable {
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
+ /// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
+ public var agentSkillLayout: AIAgentSkillLayout {
+ Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
+ }
+
+ public var agentUserSkillCatalog: AIUserSkillCatalog {
+ Self.decodeUserSkillCatalog(from: defaults)
+ }
// MARK: - Writes
@@ -199,6 +207,94 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
+ public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
+ do {
+ let data = try JSONEncoder().encode(
+ layout.sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: agentUserSkillCatalog))
+ )
+ defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
+ } catch {
+ OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
+ }
+ AppGroupConfigDarwin.postConfigChanged()
+ }
+
+ public func setAgentUserSkillCatalog(_ catalog: AIUserSkillCatalog) {
+ do {
+ defaults.set(
+ try JSONEncoder().encode(catalog),
+ forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog
+ )
+ } catch {
+ OSGLog.config.warning(
+ "agentUserSkillCatalog encode failed: \(error.localizedDescription, privacy: .public)"
+ )
+ }
+ AppGroupConfigDarwin.postConfigChanged()
+ }
+
+ public func setPendingShortcutRun(skillID: String, titles: [String]) {
+ let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
+ if let data = AIAgentShortcutRun.encode(payload) {
+ defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
+ AIAgentShortcutRun.trace(
+ "appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
+ )
+ } else {
+ AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
+ }
+ }
+
+ public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
+ defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
+ guard let data else {
+ AIAgentShortcutRun.trace("appGroup.consumePending missing")
+ return nil
+ }
+ guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
+ )
+ return nil
+ }
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
+ )
+ return payload
+ }
+
+ private static func decodeAgentSkillLayout(
+ from defaults: UserDefaults,
+ userCatalog: AIUserSkillCatalog
+ ) -> AIAgentSkillLayout {
+ let catalog = AIClipboardSkillCatalog.all(userCatalog: userCatalog)
+ guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
+ return .default
+ }
+ do {
+ return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data)
+ .sanitized(catalog: catalog)
+ } catch {
+ OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
+ return .default
+ }
+ }
+
+ private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
+ guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
+ return .empty
+ }
+ do {
+ return try JSONDecoder().decode(AIUserSkillCatalog.self, from: data)
+ } catch {
+ OSGLog.config.warning(
+ "agentUserSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)"
+ )
+ return .empty
+ }
+ }
+
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift
index ee49223..6cdb59f 100644
--- a/OSGKeyboardShared/Services/KeyboardState.swift
+++ b/OSGKeyboardShared/Services/KeyboardState.swift
@@ -140,6 +140,15 @@ public final class KeyboardState: ObservableObject {
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
+ /// Skills-tab order for clipboard chips (max 8). Empty → hint carousel.
+ @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.
@Published public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
@@ -213,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.
@@ -239,8 +273,10 @@ public final class KeyboardState: ObservableObject {
public var sendAIAnswer: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
- /// Sends a clipboard skill (reply / summarize / translate / future).
+ /// Sends a clipboard skill (reply / summarize / translate / export).
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
+ /// Writes extract-todos titles and opens the host to run the Shortcut.
+ public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
diff --git a/OSGKeyboardShared/Typing/PeriodShortcut.swift b/OSGKeyboardShared/Typing/PeriodShortcut.swift
new file mode 100644
index 0000000..7ad0f6a
--- /dev/null
+++ b/OSGKeyboardShared/Typing/PeriodShortcut.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/Typing/TypingSessionController.swift b/OSGKeyboardShared/Typing/TypingSessionController.swift
index e194786..2156d2c 100644
--- a/OSGKeyboardShared/Typing/TypingSessionController.swift
+++ b/OSGKeyboardShared/Typing/TypingSessionController.swift
@@ -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
diff --git a/OSGKeyboardShared/Typing/TypingTouchTracker.swift b/OSGKeyboardShared/Typing/TypingTouchTracker.swift
new file mode 100644
index 0000000..74b7cd0
--- /dev/null
+++ b/OSGKeyboardShared/Typing/TypingTouchTracker.swift
@@ -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 {
+ 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
+ }
+}
diff --git a/OSGKeyboardShared/Utilities/OSGDiag.swift b/OSGKeyboardShared/Utilities/OSGDiag.swift
index 83d5d05..b0eef3c 100644
--- a/OSGKeyboardShared/Utilities/OSGDiag.swift
+++ b/OSGKeyboardShared/Utilities/OSGDiag.swift
@@ -18,6 +18,8 @@ public enum OSGDiag {
OSGLog.keyboardExt.info("\(line, privacy: .public)")
case "flow", "asr":
OSGLog.flow.info("\(line, privacy: .public)")
+ case "skills":
+ OSGLog.config.info("\(line, privacy: .public)")
default:
OSGLog.config.info("\(line, privacy: .public)")
}
diff --git a/OSGKeyboardTests/AIAddressExtractionTests.swift b/OSGKeyboardTests/AIAddressExtractionTests.swift
new file mode 100644
index 0000000..5cdf8b5
--- /dev/null
+++ b/OSGKeyboardTests/AIAddressExtractionTests.swift
@@ -0,0 +1,180 @@
+// AIAddressExtractionTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class AIAddressExtractionTests: XCTestCase {
+ func testNONEAndEmptyProduceNoItems() {
+ XCTAssertEqual(AIAddressExtraction.lines(from: "NONE"), [])
+ XCTAssertEqual(AIAddressExtraction.lines(from: "没有地址"), [])
+ XCTAssertEqual(AIAddressExtraction.lines(from: "no address"), [])
+ XCTAssertEqual(AIAddressExtraction.lines(from: " \n "), [])
+ }
+
+ func testDestinationOnlyKeepsLeadingPipe() {
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "|三里屯太古里"),
+ ["|三里屯太古里"]
+ )
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "朝阳区酒仙桥路10号"),
+ ["|朝阳区酒仙桥路10号"]
+ )
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "|三里屯|"),
+ ["|三里屯"]
+ )
+ }
+
+ func testOriginAndDestination() {
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "北京南站|三里屯太古里"),
+ ["北京南站|三里屯太古里"]
+ )
+ }
+
+ func testFirstValidLineOnly() {
+ let raw = """
+ NONE
+ 北京南站|三里屯
+ 国贸|望京
+ """
+ XCTAssertEqual(AIAddressExtraction.lines(from: raw), ["北京南站|三里屯"])
+ }
+
+ func testSameOriginAndDestinationDropsOrigin() {
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "故宫|故宫"),
+ ["|故宫"]
+ )
+ }
+
+ func testRejectsURLDestinations() {
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "|https://maps.apple.com/?daddr=x"),
+ []
+ )
+ }
+
+ func testStripsBullet() {
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: "- |三里屯"),
+ ["|三里屯"]
+ )
+ }
+
+ func testWholeClipboardEchoIsRejected() {
+ let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
+ XCTAssertEqual(
+ AIAddressExtraction.lines(from: source, sourceClipboard: source),
+ []
+ )
+ }
+
+ func testPromptIncludesPipeContractAndNONE() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.navigateID,
+ locale: "zh",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
+ )
+ XCTAssertTrue(prompt.contains("起点|终点"))
+ XCTAssertTrue(prompt.contains("NONE"))
+ XCTAssertTrue(prompt.contains("不要把整段原文当成一个地点"))
+ }
+
+ func testEnglishPromptIncludesNONE() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.navigateID,
+ locale: "en",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
+ )
+ XCTAssertTrue(prompt.contains("origin|destination"))
+ XCTAssertTrue(prompt.contains("NONE"))
+ }
+}
+
+final class AIMapNavigationTests: XCTestCase {
+ private let destinationOnly = AIMapRoute(origin: nil, destination: "三里屯太古里")
+ private let twoPoint = AIMapRoute(origin: "北京南站", destination: "三里屯太古里")
+
+ func testPrefersAmapWhenIosamapOpens() {
+ let url = AIMapNavigation.url(for: destinationOnly) { $0.scheme == "iosamap" }
+ XCTAssertEqual(url.scheme, "iosamap")
+ XCTAssertEqual(url.host, "path")
+ let items = query(url)
+ XCTAssertEqual(items["sourceApplication"], "OSGKeyboard")
+ XCTAssertEqual(items["dname"], "三里屯太古里")
+ XCTAssertEqual(items["sname"], "我的位置")
+ XCTAssertEqual(items["t"], "0")
+ }
+
+ func testAmapUsesAmapuriWhenOnlyAmapuriOpens() {
+ let url = AIMapNavigation.url(for: twoPoint) { $0.scheme == "amapuri" }
+ XCTAssertEqual(url.scheme, "amapuri")
+ XCTAssertEqual(url.host, "route")
+ XCTAssertTrue(url.absoluteString.contains("://route/plan/?"))
+ let items = query(url)
+ XCTAssertEqual(items["sname"], "北京南站")
+ XCTAssertEqual(items["dname"], "三里屯太古里")
+ }
+
+ func testFallsBackToBaiduWhenAmapMissing() {
+ let url = AIMapNavigation.url(for: twoPoint) { $0.scheme == "baidumap" }
+ XCTAssertEqual(url.scheme, "baidumap")
+ XCTAssertEqual(url.host, "map")
+ XCTAssertEqual(url.path, "/direction")
+ let items = query(url)
+ XCTAssertEqual(items["origin"], "name:北京南站")
+ XCTAssertEqual(items["destination"], "name:三里屯太古里")
+ XCTAssertEqual(items["mode"], "driving")
+ }
+
+ func testBaiduOmitsOriginWhenStartingFromHere() {
+ let url = AIMapNavigation.url(for: destinationOnly) { $0.scheme == "baidumap" }
+ let items = query(url)
+ XCTAssertNil(items["origin"])
+ XCTAssertEqual(items["destination"], "name:三里屯太古里")
+ }
+
+ func testFallsBackToAppleMaps() {
+ let url = AIMapNavigation.url(for: twoPoint) { _ in false }
+ XCTAssertEqual(url.scheme, "maps")
+ XCTAssertTrue(url.absoluteString.hasPrefix("maps://"))
+ let items = query(url)
+ XCTAssertEqual(items["saddr"], "北京南站")
+ XCTAssertEqual(items["daddr"], "三里屯太古里")
+ XCTAssertEqual(items["dirflg"], "d")
+ }
+
+ func testAppleOmitsSaddrWhenStartingFromHere() {
+ let url = AIMapNavigation.url(for: destinationOnly) { _ in false }
+ let items = query(url)
+ XCTAssertNil(items["saddr"])
+ XCTAssertEqual(items["daddr"], "三里屯太古里")
+ }
+
+ func testShortcutInputEncodesFirstRoute() {
+ let text = AIMapNavigation.shortcutInput(from: "北京南站|三里屯") { _ in false }
+ XCTAssertEqual(text?.hasPrefix("maps:"), true)
+ XCTAssertTrue(text?.contains("daddr") == true)
+ }
+
+ func testShortcutInputRejectsNONE() {
+ XCTAssertNil(AIMapNavigation.shortcutInput(from: "NONE") { _ in false })
+ }
+
+ func testProviderOrder() {
+ XCTAssertEqual(AIMapNavigation.provider { $0.scheme == "iosamap" }, .amap)
+ XCTAssertEqual(AIMapNavigation.provider { $0.scheme == "baidumap" }, .baidu)
+ XCTAssertEqual(AIMapNavigation.provider { _ in false }, .apple)
+ }
+
+ private func query(_ url: URL) -> [String: String] {
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ return Dictionary(uniqueKeysWithValues: items.compactMap { item in
+ guard let value = item.value else { return nil }
+ return (item.name, value)
+ })
+ }
+}
diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
new file mode 100644
index 0000000..aa01e59
--- /dev/null
+++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
@@ -0,0 +1,284 @@
+// AIAgentSkillLayoutTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+@MainActor
+final class AIAgentSkillLayoutTests: XCTestCase {
+ private func makeDefaults() -> UserDefaults {
+ let suite = "group.com.osgkeyboard.shared.tests.skills.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ return defaults
+ }
+
+ func testFreshInstallEnablesDefaultTransformSkills() {
+ let defaults = makeDefaults()
+ let layout = AppGroupStore(defaults: defaults).agentSkillLayout
+ XCTAssertEqual(layout.enabledIDs, AIAgentSkillLayout.defaultEnabledIDs)
+ XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
+ }
+
+ func testEmptyEnabledListIsPreserved() {
+ let defaults = makeDefaults()
+ let store = AppGroupStore(defaults: defaults)
+ store.setAgentSkillLayout(
+ AIAgentSkillLayout(enabledIDs: [], confirmedShortcutIDs: [])
+ )
+ XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
+ }
+
+ func testCannotEnableExportSkillBeforeShortcutConfirmation() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.enable(AIClipboardSkillCatalog.extractTodosID),
+ .needsShortcut
+ )
+ XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
+ }
+
+ func testConfirmShortcutAutoEnablesWhenSlotAvailable() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID),
+ .enabled
+ )
+ XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
+ XCTAssertEqual(
+ store.layout.enabledIDs.last,
+ AIClipboardSkillCatalog.extractTodosID
+ )
+ }
+
+ func testSanitizedDropsUnconfirmedExportAndUnknownIDs() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos", "unknown"],
+ confirmedShortcutIDs: []
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply"])
+ }
+
+ func testSanitizedKeepsConfirmedExport() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos"],
+ confirmedShortcutIDs: ["extractTodos"]
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply", "extractTodos"])
+ }
+
+ func testIsFullUsesEnabledCount() {
+ let full = AIAgentSkillLayout(
+ enabledIDs: (0.."))
+ 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, "OSGExtractTodos")
+ XCTAssertEqual(items.first { $0.name == "input" }?.value, "text")
+ XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件")
+ XCTAssertNil(items.first { $0.name == "x-success" })
+ }
+
+ func testXCallbackRunURLUsesCallbackHost() {
+ let url = AIAgentShortcutRun.shortcutsRunURL(
+ name: AIClipboardSkillCatalog.extractTodosShortcutName,
+ text: "买牛奶",
+ xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
+ xError: "osgkeyboard://skill/shortcut-result?status=error",
+ xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
+ )
+ XCTAssertEqual(url?.scheme, "shortcuts")
+ XCTAssertEqual(url?.host, "x-callback-url")
+ XCTAssertEqual(url?.path, "/run-shortcut")
+ let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶")
+ XCTAssertEqual(
+ items.first { $0.name == "x-success" }?.value,
+ "osgkeyboard://skill/shortcut-result?status=success"
+ )
+ }
+
+ func testPreviewEscapesNewlines() {
+ XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件")
+ }
+
+ func testExtractTodosUsesBundledShortcut() {
+ let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID)
+ XCTAssertNil(skill?.shortcutICloudURL)
+ XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos")
+ XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
+ }
+
+ func testExtractEventsUsesBundledShortcut() {
+ let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
+ XCTAssertNil(skill?.shortcutICloudURL)
+ XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents")
+ XCTAssertEqual(
+ skill?.shortcutResourceName,
+ "OSGExtractEvents"
+ )
+ XCTAssertEqual(skill?.systemImage, "calendar")
+ XCTAssertFalse(skill?.isDefault ?? true)
+ }
+
+ func testNavigateDoesNotRequireShortcut() {
+ let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID)
+ XCTAssertNil(skill?.shortcutName)
+ XCTAssertNil(skill?.shortcutICloudURL)
+ 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)
+ XCTAssertEqual(url?.scheme, "shortcuts")
+ XCTAssertEqual(url?.host, "shortcuts")
+ XCTAssertEqual(url?.path, "/65bf33ba4206484ba78d582eaf1e9c44")
+ XCTAssertEqual(
+ AIAgentShortcutRun.iCloudShareToken(from: share),
+ "65bf33ba4206484ba78d582eaf1e9c44"
+ )
+ }
+}
diff --git a/OSGKeyboardTests/AIEventExtractionTests.swift b/OSGKeyboardTests/AIEventExtractionTests.swift
new file mode 100644
index 0000000..1285ab6
--- /dev/null
+++ b/OSGKeyboardTests/AIEventExtractionTests.swift
@@ -0,0 +1,143 @@
+// AIEventExtractionTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class AIEventExtractionTests: 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, 20, 24)
+ }
+
+ func testNONEAndEmptyProduceNoItems() {
+ XCTAssertEqual(lines("NONE"), [])
+ XCTAssertEqual(lines("没有日期或时间"), [])
+ XCTAssertEqual(lines("no events"), [])
+ XCTAssertEqual(lines(" \n "), [])
+ }
+
+ func testAllDayDateOnly() {
+ XCTAssertEqual(
+ lines("2026-08-15||提交周报|"),
+ ["2026-08-15|ALLDAY|提交周报|"]
+ )
+ }
+
+ func testTimedFillsDefaultOneHour() {
+ XCTAssertEqual(
+ lines("2026-08-15 14:00||项目评审|"),
+ ["2026-08-15 14:00|2026-08-15 15:00|项目评审|"]
+ )
+ }
+
+ func testTimedKeepsExplicitEndAndLocation() {
+ XCTAssertEqual(
+ lines("2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室"),
+ ["2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室"]
+ )
+ }
+
+ func testTimeOnlyUsesToday() {
+ XCTAssertEqual(
+ lines("15:00||打电话给客户|"),
+ ["2026-08-13 15:00|2026-08-13 16:00|打电话给客户|"]
+ )
+ }
+
+ func testTimeOnlyEndOnSameDayOvernightRollsForward() {
+ XCTAssertEqual(
+ lines("2026-08-15 23:00|01:00|跨夜值班|"),
+ ["2026-08-15 23:00|2026-08-16 01:00|跨夜值班|"]
+ )
+ }
+
+ func testTwoFieldStartAndTitle() {
+ XCTAssertEqual(
+ lines("2026-08-15 14:00|开会"),
+ ["2026-08-15 14:00|2026-08-15 15:00|开会|"]
+ )
+ }
+
+ func testThreeFieldStartTitleLocationWhenMiddleIsNotATime() {
+ XCTAssertEqual(
+ lines("2026-08-15 14:00|开会|会议室A"),
+ ["2026-08-15 14:00|2026-08-15 15:00|开会|会议室A"]
+ )
+ }
+
+ func testDropsLinesWithoutStartOrTitle() {
+ let raw = """
+ 买牛奶
+ ||无开始|
+ 2026-08-15 14:00||
+ 2026-08-16||有效全天|
+ """
+ XCTAssertEqual(lines(raw), ["2026-08-16|ALLDAY|有效全天|"])
+ }
+
+ func testMultipleEventsCapAtTwenty() {
+ let raw = (1...25).map { "2026-08-15 10:00||任务\($0)|" }.joined(separator: "\n")
+ let items = lines(raw)
+ XCTAssertEqual(items.count, 20)
+ XCTAssertEqual(items.first, "2026-08-15 10:00|2026-08-15 11:00|任务1|")
+ XCTAssertEqual(items.last, "2026-08-15 10:00|2026-08-15 11:00|任务20|")
+ }
+
+ func testWholeClipboardEchoIsRejected() {
+ let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
+ XCTAssertEqual(
+ AIEventExtraction.lines(
+ from: "2026-08-15||\(source)|",
+ sourceClipboard: source,
+ now: now,
+ calendar: calendar
+ ),
+ []
+ )
+ }
+
+ func testPromptIncludesClockPipeContractAndNONE() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.extractEventsID,
+ locale: "zh",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
+ now: now
+ )
+ XCTAssertTrue(prompt.contains("本地时区"))
+ XCTAssertTrue(prompt.contains("开始|结束|标题|地点"))
+ XCTAssertTrue(prompt.contains("NONE"))
+ XCTAssertTrue(prompt.contains("不要把整段原文当成一条日程"))
+ }
+
+ func testEnglishPromptIncludesClockAndNONE() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.extractEventsID,
+ locale: "en",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
+ now: now
+ )
+ XCTAssertTrue(prompt.contains("local timezone"))
+ XCTAssertTrue(prompt.contains("NONE"))
+ XCTAssertTrue(prompt.contains("all-day"))
+ }
+
+ private func lines(_ raw: String) -> [String] {
+ AIEventExtraction.lines(from: raw, now: now, calendar: calendar)
+ }
+
+ private func date(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ min: Int) -> Date {
+ var components = DateComponents()
+ components.year = y
+ components.month = m
+ components.day = d
+ components.hour = h
+ components.minute = min
+ return calendar.date(from: components)!
+ }
+}
diff --git a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift
index 032a7da..ee148bb 100644
--- a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift
+++ b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift
@@ -235,4 +235,45 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(prompt.contains("概括"))
XCTAssertTrue(prompt.contains("不要改写成可发送的短消息"))
}
+
+ func testExtractTodosAsksForNONEWhenEmpty() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.extractTodosID,
+ locale: "zh",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
+ )
+ XCTAssertTrue(prompt.contains("NONE"))
+ XCTAssertTrue(prompt.contains("不要把整段原文当成一条待办"))
+ }
+
+ func testExtractEventsAsksForNONEWhenEmpty() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.extractEventsID,
+ locale: "zh",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
+ )
+ XCTAssertTrue(prompt.contains("NONE"))
+ XCTAssertTrue(prompt.contains("开始|结束|标题|地点"))
+ }
+
+ func testNavigateAsksForNONEWhenEmpty() {
+ let prompt = AIClipboardSkillCatalog.instruction(
+ skillID: AIClipboardSkillCatalog.navigateID,
+ locale: "zh",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
+ )
+ 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"))
+ }
}
diff --git a/OSGKeyboardTests/AIModeLLMClientTests.swift b/OSGKeyboardTests/AIModeLLMClientTests.swift
index c43f29c..c43b750 100644
--- a/OSGKeyboardTests/AIModeLLMClientTests.swift
+++ b/OSGKeyboardTests/AIModeLLMClientTests.swift
@@ -77,6 +77,18 @@ final class AIModeLLMClientTests: XCTestCase {
XCTAssertTrue(client is AIModeSearchFallbackClient)
}
+ func testFactorySkipsSearchWhenThinkingDisabled() {
+ let client = AIModeLLMClientFactory.make(
+ providerId: "openai",
+ baseURL: "https://api.openai.com/v1",
+ apiKey: "sk-test",
+ model: "gpt-5.4-mini",
+ thinkingEnabled: false
+ )
+ XCTAssertFalse(client is AIModeSearchFallbackClient)
+ XCTAssertTrue(client is OpenAICompatibleClient)
+ }
+
func testFactoryPlainForGroq() {
let client = AIModeLLMClientFactory.make(
providerId: "groq",
diff --git a/OSGKeyboardTests/AINoteExportTests.swift b/OSGKeyboardTests/AINoteExportTests.swift
new file mode 100644
index 0000000..a98515b
--- /dev/null
+++ b/OSGKeyboardTests/AINoteExportTests.swift
@@ -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
+ )
+ )!
+ }
+}
diff --git a/OSGKeyboardTests/AIUserSkillTests.swift b/OSGKeyboardTests/AIUserSkillTests.swift
new file mode 100644
index 0000000..0275f8a
--- /dev/null
+++ b/OSGKeyboardTests/AIUserSkillTests.swift
@@ -0,0 +1,189 @@
+// AIUserSkillTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class AIUserSkillTests: XCTestCase {
+ private let sampleURL = URL(
+ string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
+ )!
+
+ func testValidICloudShareLink() {
+ XCTAssertNotNil(AIShortcutShareLink.parse(
+ "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
+ ))
+ XCTAssertNotNil(AIShortcutShareLink.parse(
+ "https://icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
+ ))
+ XCTAssertNil(AIShortcutShareLink.parse("https://example.com/shortcuts/abc"))
+ XCTAssertNil(AIShortcutShareLink.parse("https://www.icloud.com/shortcuts/api/records/x"))
+ XCTAssertNil(AIShortcutShareLink.parse("not a url"))
+ XCTAssertNil(AIShortcutShareLink.parse("https://www.icloud.com/shortcuts/short"))
+ }
+
+ func testParsesShortcutNameFromRecordsJSON() throws {
+ let json = """
+ {"fields":{"name":{"type":"STRING","value":"OSG · 提取待办"}}}
+ """.data(using: .utf8)!
+ XCTAssertEqual(try AIShortcutShareMetadata.name(fromRecordsJSON: json), "OSG · 提取待办")
+ }
+
+ func testParsesShortcutNameFromLegacyRecordsArray() throws {
+ let json = """
+ {"records":[{"fields":{"name":{"value":"OSG · 提取日程"}}}]}
+ """.data(using: .utf8)!
+ XCTAssertEqual(try AIShortcutShareMetadata.name(fromRecordsJSON: json), "OSG · 提取日程")
+ }
+
+ func testRejectsRecordsJSONWithoutName() {
+ let json = Data(#"{"fields":{}}"#.utf8)
+ XCTAssertThrowsError(try AIShortcutShareMetadata.name(fromRecordsJSON: json))
+ }
+
+ func testCatalogUpsertKeepsShortcutNameIndependentOfSkillName() throws {
+ var catalog = AIUserSkillCatalog()
+ var skill = AIUserSkill(
+ name: "会议纪要",
+ summary: "抽要点",
+ prompt: "提取要点",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "OSG · 提取待办"
+ )
+ try catalog.upsert(skill)
+ XCTAssertEqual(catalog.entries.first?.name, "会议纪要")
+ XCTAssertEqual(catalog.entries.first?.shortcutName, "OSG · 提取待办")
+
+ skill.name = "纪要"
+ skill.shortcutName = "My Tasks"
+ try catalog.upsert(skill)
+ XCTAssertEqual(catalog.entries.count, 1)
+ XCTAssertEqual(catalog.entries.first?.name, "纪要")
+ XCTAssertEqual(catalog.entries.first?.shortcutName, "My Tasks")
+ }
+
+ func testThinkingDefaultsOffAndBuiltinCannotEnable() {
+ let user = AIUserSkill(
+ name: "Custom",
+ prompt: "Do it",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "Run Me"
+ )
+ XCTAssertFalse(user.thinkingEnabled)
+ XCTAssertFalse(user.asClipboardSkill().thinkingEnabled)
+
+ let withThinking = AIUserSkill(
+ name: "Custom",
+ prompt: "Do it",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "Run Me",
+ thinkingEnabled: true
+ )
+ XCTAssertTrue(withThinking.asClipboardSkill().thinkingEnabled)
+
+ let builtin = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
+ XCTAssertEqual(builtin?.thinkingEnabled, false)
+ }
+
+ func testInstructionUsesCustomPrompt() {
+ let skill = AIUserSkill(
+ name: "Custom",
+ prompt: "只输出一行标题",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "Run Me"
+ ).asClipboardSkill()
+ XCTAssertEqual(
+ AIClipboardSkillCatalog.instruction(
+ for: skill,
+ locale: "zh",
+ translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
+ ),
+ "只输出一行标题"
+ )
+ }
+
+ func testVisibleIncludesUserSkills() throws {
+ var catalog = AIUserSkillCatalog()
+ let skill = AIUserSkill(
+ name: "Custom",
+ prompt: "Do it",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "Run Me"
+ )
+ try catalog.upsert(skill)
+ let visible = AIClipboardSkillCatalog.visible(
+ enabledIDs: [skill.id],
+ userCatalog: catalog
+ )
+ XCTAssertEqual(visible.map(\.id), [skill.id])
+ XCTAssertEqual(visible.first?.customName, "Custom")
+ }
+
+ func testSanitizeKeepsConfirmedUserExportSkill() throws {
+ var catalog = AIUserSkillCatalog()
+ let skill = AIUserSkill(
+ name: "Custom",
+ prompt: "Do it",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "Run Me"
+ )
+ try catalog.upsert(skill)
+ let layout = AIAgentSkillLayout(
+ enabledIDs: [skill.id],
+ confirmedShortcutIDs: [skill.id]
+ ).sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: catalog))
+ XCTAssertEqual(layout.enabledIDs, [skill.id])
+ }
+
+ func testGenericExportSplitsLinesAndHonorsNONE() {
+ XCTAssertEqual(AIGenericSkillExport.items(from: "NONE"), [])
+ XCTAssertEqual(AIGenericSkillExport.items(from: "买牛奶\n回邮件"), ["买牛奶", "回邮件"])
+ XCTAssertEqual(AIGenericSkillExport.items(from: "一段没有换行的结果"), ["一段没有换行的结果"])
+ }
+
+ func testNoUserSkillLimit() throws {
+ var catalog = AIUserSkillCatalog()
+ for index in 0..<12 {
+ try catalog.upsert(
+ AIUserSkill(
+ name: "Skill \(index)",
+ prompt: "Do it",
+ shortcutICloudURL: sampleURL,
+ shortcutName: "Run \(index)"
+ )
+ )
+ }
+ XCTAssertEqual(catalog.entries.count, 12)
+ }
+}
+
+@MainActor
+final class AIUserSkillStoreTests: XCTestCase {
+ private func makeDefaults() -> UserDefaults {
+ let suite = "group.com.osgkeyboard.shared.tests.userSkills.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ return defaults
+ }
+
+ func testChangingShortcutLinkDropsConfirmation() throws {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ let firstURL = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
+ let secondURL = URL(string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf")!
+ var skill = AIUserSkill(
+ name: "Custom",
+ prompt: "Do it",
+ shortcutICloudURL: firstURL,
+ shortcutName: "One"
+ )
+ try store.saveUserSkill(skill)
+ XCTAssertEqual(store.confirmShortcutAndEnable(skill.id), .enabled)
+ XCTAssertTrue(store.layout.isEnabled(skill.id))
+
+ skill.shortcutICloudURL = secondURL
+ skill.shortcutName = "Two"
+ try store.saveUserSkill(skill)
+ XCTAssertFalse(store.layout.hasConfirmedShortcut(skill.id))
+ XCTAssertFalse(store.layout.isEnabled(skill.id))
+ }
+}
diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift
index 78b28d1..24be168 100644
--- a/OSGKeyboardTests/FlowSessionBridgeTests.swift
+++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift
@@ -323,6 +323,47 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertEqual(decoded, command)
XCTAssertEqual(decoded.aiQuestionText, "总结这段剪贴板内容")
+ XCTAssertNil(decoded.aiThinkingEnabled)
+ }
+
+ func testSubmitAIQuestionCommandRoundTripsThinkingOverride() throws {
+ let command = FlowCommand(
+ sessionId: UUID(),
+ utteranceId: UUID(),
+ commandSeq: 45,
+ action: .submitAIQuestion,
+ localeId: "zh-Hans",
+ utteranceMode: .aiQuestion,
+ aiConversationID: UUID(),
+ aiQuestionText: "总结这段剪贴板内容",
+ aiThinkingEnabled: false
+ )
+ let decoded = try JSONDecoder().decode(
+ FlowCommand.self,
+ from: JSONEncoder().encode(command)
+ )
+ XCTAssertEqual(decoded.aiThinkingEnabled, false)
+ }
+
+ func testFlowCommandDecodesLegacyJSONWithoutThinkingKey() throws {
+ let command = FlowCommand(
+ sessionId: UUID(),
+ utteranceId: UUID(),
+ commandSeq: 44,
+ action: .submitAIQuestion,
+ localeId: "zh-Hans",
+ utteranceMode: .aiQuestion,
+ aiConversationID: UUID(),
+ aiQuestionText: "总结这段剪贴板内容"
+ )
+ var object = try JSONSerialization.jsonObject(
+ with: JSONEncoder().encode(command)
+ ) as! [String: Any]
+ object.removeValue(forKey: "aiThinkingEnabled")
+ let data = try JSONSerialization.data(withJSONObject: object)
+ let decoded = try JSONDecoder().decode(FlowCommand.self, from: data)
+ XCTAssertNil(decoded.aiThinkingEnabled)
+ XCTAssertEqual(decoded.aiQuestionText, "总结这段剪贴板内容")
}
func testSecureFieldContextRedactsText() {
diff --git a/Scripts/generate_extract_events_shortcut.py b/Scripts/generate_extract_events_shortcut.py
new file mode 100644
index 0000000..7abafa1
--- /dev/null
+++ b/Scripts/generate_extract_events_shortcut.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""Build the companion Shortcut for the Extract Events skill.
+
+Writes an unsigned binary plist that `shortcuts sign` can notarize.
+
+Workflow (must materialize Shortcut Input as Text first — Split Text's
+WFInput/ExtensionInput binding finishes the run without iterating):
+
+ Text (Shortcut Input)
+ → Split Text (new lines)
+ → Repeat Each
+ → Split Text (|)
+ → Get items 1–4 (start, end, title, location)
+ → If end == ALLDAY
+ → Add New Event (all-day)
+ Otherwise
+ → Add New Event (timed)
+"""
+
+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 / "OSGExtractEvents.unsigned.shortcut"
+SIGNED = OUT_DIR / "OSGExtractEvents.shortcut"
+SHORTCUT_NAME = "OSGExtractEvents"
+
+OBJECT_REPLACEMENT = "\ufffc"
+
+TEXT_UUID = "A1B2C3D0-1111-4E00-8A5A-FD7E1B0C396F"
+SPLIT_LINES_UUID = "A1B2C3D1-2222-4F11-9A6B-0E8F2C1D4A70"
+REPEAT_GROUP = "A1B2C3D2-3333-4E02-B3C4-91F0E6A7B812"
+REPEAT_START = "A1B2C3D3-4444-4F13-A4D5-02A1F7B8C923"
+SPLIT_FIELDS_UUID = "A1B2C3D4-5555-4014-B5E6-13B2A8C9D034"
+START_UUID = "A1B2C3D5-6666-4125-C6F7-24C3B9DAE145"
+END_UUID = "A1B2C3D6-7777-4236-D7A8-35D4CAEBF256"
+TITLE_UUID = "A1B2C3D7-8888-4347-E8B9-46E5DBF0C367"
+LOCATION_UUID = "A1B2C3D8-9999-4458-F9CA-57F6EC01D478"
+IF_GROUP = "A1B2C3D9-AAAA-4569-0ADB-6807FD12E589"
+IF_START = "A1B2C3DA-BBBB-467A-1BEC-79180E23F69A"
+EVENT_ALLDAY_UUID = "A1B2C3DB-CCCC-478B-2CFD-8A291F3407AB"
+IF_ELSE = "A1B2C3DC-DDDD-489C-3D0E-9B3A204518BC"
+EVENT_TIMED_UUID = "A1B2C3DD-EEEE-49AD-4E1F-AC4B315629CD"
+IF_END = "A1B2C3DE-FFFF-4ABE-5F20-BD5C42673ADE"
+REPEAT_END = "A1B2C3DF-0000-4BCF-6031-CE6D53784BEF"
+
+
+def attachment(value: dict) -> dict:
+ return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
+
+
+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 attachment(
+ {
+ "Aggrandizements": [],
+ "OutputName": output_name,
+ "OutputUUID": output_uuid,
+ "Type": "ActionOutput",
+ }
+ )
+
+
+def named_variable_text(variable_name: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "Type": "Variable",
+ "VariableName": variable_name,
+ }
+ )
+
+
+def if_input(output_name: str, output_uuid: str) -> dict:
+ return {
+ "Type": "Variable",
+ "Variable": {
+ "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_FIELDS_UUID),
+ "WFItemSpecifier": "Item At Index",
+ "WFItemIndex": index,
+ },
+ }
+
+
+def add_event(
+ uuid: str,
+ *,
+ all_day: bool,
+ include_end: bool,
+) -> dict:
+ parameters = {
+ "UUID": uuid,
+ "WFCalendarItemTitle": action_output_text("标题", TITLE_UUID),
+ "WFCalendarItemLocation": action_output_text("地点", LOCATION_UUID),
+ "WFCalendarItemDates": True,
+ "WFCalendarItemStartDate": action_output_text("开始", START_UUID),
+ "WFCalendarItemAllDay": all_day,
+ "WFCalendarItemShowComposer": False,
+ "ShowComposeSheet": False,
+ "WFShowWhenRun": False,
+ }
+ if include_end:
+ parameters["WFCalendarItemEndDate"] = action_output_text("结束", END_UUID)
+ return {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.addnewevent",
+ "WFWorkflowActionParameters": parameters,
+ }
+
+
+def workflow() -> dict:
+ return {
+ "WFWorkflowClientVersion": "3600.0.4",
+ "WFWorkflowClientRelease": "26.0",
+ "WFWorkflowMinimumClientVersion": 900,
+ "WFWorkflowMinimumClientVersionString": "900",
+ "WFWorkflowName": SHORTCUT_NAME,
+ "WFWorkflowHasShortcutInputVariables": True,
+ "WFWorkflowHasOutputFallback": False,
+ "WFWorkflowImportQuestions": [],
+ "WFWorkflowTypes": [],
+ "WFWorkflowInputContentItemClasses": [
+ "WFStringContentItem",
+ "WFRichTextContentItem",
+ ],
+ "WFWorkflowOutputContentItemClasses": [],
+ "WFWorkflowNoInputBehavior": {
+ "Name": "WFWorkflowNoInputBehaviorAskForInput",
+ "Parameters": {
+ "ItemClass": "WFStringContentItem",
+ },
+ },
+ "WFWorkflowIcon": {
+ "WFWorkflowIconGlyphNumber": 59675,
+ "WFWorkflowIconStartColor": 4286828031,
+ },
+ "WFWorkflowActions": [
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
+ "WFWorkflowActionParameters": {
+ "UUID": TEXT_UUID,
+ "CustomOutputName": "快捷指令文本",
+ "WFTextActionText": shortcut_input_text(),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
+ "WFWorkflowActionParameters": {
+ "UUID": SPLIT_LINES_UUID,
+ "CustomOutputName": "行",
+ "text": action_output_text("快捷指令文本", TEXT_UUID),
+ "WFTextSeparator": "New Lines",
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_START,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 0,
+ "WFInput": action_output_attachment("行", SPLIT_LINES_UUID),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
+ "WFWorkflowActionParameters": {
+ "UUID": SPLIT_FIELDS_UUID,
+ "CustomOutputName": "字段",
+ "text": named_variable_text("Repeat Item"),
+ "WFTextSeparator": "Custom",
+ "WFTextCustomSeparator": "|",
+ },
+ },
+ get_item(START_UUID, "开始", 1),
+ get_item(END_UUID, "结束", 2),
+ get_item(TITLE_UUID, "标题", 3),
+ get_item(LOCATION_UUID, "地点", 4),
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
+ "WFWorkflowActionParameters": {
+ "UUID": IF_START,
+ "GroupingIdentifier": IF_GROUP,
+ "WFControlFlowMode": 0,
+ "WFCondition": 4,
+ "WFConditionalActionString": "ALLDAY",
+ "WFInput": if_input("结束", END_UUID),
+ },
+ },
+ add_event(EVENT_ALLDAY_UUID, all_day=True, include_end=False),
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
+ "WFWorkflowActionParameters": {
+ "UUID": IF_ELSE,
+ "GroupingIdentifier": IF_GROUP,
+ "WFControlFlowMode": 1,
+ },
+ },
+ add_event(EVENT_TIMED_UUID, all_day=False, include_end=True),
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
+ "WFWorkflowActionParameters": {
+ "UUID": IF_END,
+ "GroupingIdentifier": IF_GROUP,
+ "WFControlFlowMode": 2,
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_END,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 2,
+ },
+ },
+ ],
+ }
+
+
+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()
diff --git a/Scripts/generate_extract_todos_shortcut.py b/Scripts/generate_extract_todos_shortcut.py
new file mode 100644
index 0000000..089e7d3
--- /dev/null
+++ b/Scripts/generate_extract_todos_shortcut.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""Build the companion Shortcut for the Extract Tasks skill.
+
+Writes an unsigned binary plist that `shortcuts sign` can notarize.
+
+Workflow (must materialize Shortcut Input as Text first — Split Text's
+WFInput/ExtensionInput binding finishes the run without iterating):
+
+ Text (Shortcut Input)
+ → Split Text (new lines)
+ → Repeat Each
+ → Add New Reminder (title = Repeat Item, default list, no composer)
+"""
+
+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 / "OSGExtractTodos.unsigned.shortcut"
+SIGNED = OUT_DIR / "OSGExtractTodos.shortcut"
+SHORTCUT_NAME = "OSGExtractTodos"
+
+OBJECT_REPLACEMENT = "\ufffc"
+
+TEXT_UUID = "6A2D0A80-3B1C-4E00-8A5A-FD7E1B0C396F"
+SPLIT_UUID = "7A3E1B90-4C2D-4F11-9A6B-0E8F2C1D4A70"
+REPEAT_GROUP = "2B9C8D11-55AA-4E02-B3C4-91F0E6A7B812"
+REPEAT_START = "3C0D9E22-66BB-4F13-A4D5-02A1F7B8C923"
+REMINDER_UUID = "4D1E0F33-77CC-4014-B5E6-13B2A8C9D034"
+REPEAT_END = "5E2F1044-88DD-4125-C6F7-24C3B9DAE145"
+
+
+def attachment(value: dict) -> dict:
+ return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
+
+
+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 named_variable_text(variable_name: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "Type": "Variable",
+ "VariableName": variable_name,
+ }
+ )
+
+
+def workflow() -> dict:
+ return {
+ "WFWorkflowClientVersion": "3600.0.4",
+ "WFWorkflowClientRelease": "26.0",
+ "WFWorkflowMinimumClientVersion": 900,
+ "WFWorkflowMinimumClientVersionString": "900",
+ "WFWorkflowName": SHORTCUT_NAME,
+ "WFWorkflowHasShortcutInputVariables": True,
+ "WFWorkflowHasOutputFallback": False,
+ "WFWorkflowImportQuestions": [],
+ # Empty types: a normal library shortcut. QuickActions/ActionExtension
+ # made `run-shortcut?name=` open Shortcuts without running it.
+ "WFWorkflowTypes": [],
+ "WFWorkflowInputContentItemClasses": [
+ "WFStringContentItem",
+ "WFRichTextContentItem",
+ ],
+ "WFWorkflowOutputContentItemClasses": [],
+ "WFWorkflowNoInputBehavior": {
+ "Name": "WFWorkflowNoInputBehaviorAskForInput",
+ "Parameters": {
+ "ItemClass": "WFStringContentItem",
+ },
+ },
+ "WFWorkflowIcon": {
+ "WFWorkflowIconGlyphNumber": 59511,
+ "WFWorkflowIconStartColor": 2071128575,
+ },
+ "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,
+ # Split Text reads `text`, not `WFInput`.
+ "text": action_output_text("快捷指令文本", TEXT_UUID),
+ "WFTextSeparator": "New Lines",
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_START,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 0,
+ "WFInput": attachment(
+ {
+ "Aggrandizements": [],
+ "OutputName": "Split Text",
+ "OutputUUID": SPLIT_UUID,
+ "Type": "ActionOutput",
+ }
+ ),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.addnewreminder",
+ "WFWorkflowActionParameters": {
+ "UUID": REMINDER_UUID,
+ "WFCalendarItemTitle": named_variable_text("Repeat Item"),
+ "WFCalendarItemShowComposer": False,
+ "ShowComposeSheet": False,
+ "WFShowWhenRun": False,
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_END,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 2,
+ },
+ },
+ ],
+ }
+
+
+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()
diff --git a/Scripts/generate_save_to_notes_shortcut.py b/Scripts/generate_save_to_notes_shortcut.py
new file mode 100644
index 0000000..a0c61c1
--- /dev/null
+++ b/Scripts/generate_save_to_notes_shortcut.py
@@ -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()
diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json
index dd21bd0..81e1073 100644
--- a/Tests/suite-manifest.json
+++ b/Tests/suite-manifest.json
@@ -132,6 +132,12 @@
"OSGKeyboardTests/CursorNavigationTests",
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests",
+ "OSGKeyboardTests/AIHintKeywordExtractorTests",
+ "OSGKeyboardTests/AIAgentSkillLayoutTests",
+ "OSGKeyboardTests/AIUserSkillTests",
+ "OSGKeyboardTests/AIUserSkillStoreTests",
+ "OSGKeyboardTests/AIEventExtractionTests",
+ "OSGKeyboardTests/AIAddressExtractionTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
diff --git a/project.yml b/project.yml
index 00a0157..b2ca4f1 100644
--- a/project.yml
+++ b/project.yml
@@ -49,7 +49,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.7.5"
- CURRENT_PROJECT_VERSION: "67"
+ CURRENT_PROJECT_VERSION: "71"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -76,6 +76,7 @@ targets:
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
- "Resources/CustomLanguageModel/**"
+ - "Resources/Shortcuts/**"
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -87,6 +88,17 @@ targets:
buildPhase: resources
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
buildPhase: resources
+ # `.shortcut` is not a XcodeGen-known resource type; list each file
+ # as an explicit resource source or it never lands in the app bundle.
+ - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
+ type: file
+ buildPhase: resources
+ - path: OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
+ type: file
+ buildPhase: resources
+ - path: OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut
+ type: file
+ buildPhase: resources
entitlements:
path: OSGKeyboard/OSGKeyboard.entitlements
properties:
@@ -194,6 +206,11 @@ targets:
- zalo
- skype
- zoomus
+ - shortcuts
+ - iosamap
+ - amapuri
+ - baidumap
+ - maps
- things
- todoist
- evernote