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