From 148ff807f21047e7497d703a64a048ee08bd2672 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:20:42 +0800 Subject: [PATCH 1/3] feat(keyboard): add Skills tab and extract-todos Shortcut export Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders. --- CHANGELOG.md | 9 +- OSGKeyboard/Info.plist | 1 + .../Shortcuts/OSG · 提取待办.shortcut | Bin 0 -> 22481 bytes .../Shortcuts/OSGExtractTodos.shortcut | Bin 0 -> 22481 bytes .../OSGExtractTodos.unsigned.shortcut | Bin 0 -> 1948 bytes .../Services/AIAgentShortcutInstaller.swift | 41 +++ .../Services/AIAgentShortcutRunner.swift | 71 ++++ OSGKeyboard/Views/AIAgentSkillsView.swift | 341 ++++++++++++++++++ .../Views/Components/MinimalTabBar.swift | 10 +- OSGKeyboard/Views/MainAppRoot.swift | 6 + OSGKeyboard/Views/MainTabContent.swift | 2 + OSGKeyboard/en.lproj/Localizable.strings | 27 ++ OSGKeyboard/zh-Hans.lproj/Localizable.strings | 27 ++ OSGKeyboardExt/KeyboardViewController.swift | 18 + .../Services/AIKeyboardCoordinator.swift | 72 +++- .../Services/AppGroupPersistor.swift | 2 + OSGKeyboardExt/Views/AIKeyboardView.swift | 46 ++- OSGKeyboardExt/en.lproj/Keyboard.strings | 6 + OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 6 + .../Models/AIAgentSkillLayout.swift | 78 ++++ .../Models/AppGroupConfiguration.swift | 6 +- .../Services/AIAgentShortcutRun.swift | 128 +++++++ .../Services/AIAgentSkillLayoutStore.swift | 110 ++++++ .../Services/AIClipboardSkill.swift | 110 +++++- .../Services/AITodoExtraction.swift | 82 +++++ .../Services/AppGroupStore.swift | 57 +++ .../Services/KeyboardState.swift | 10 +- OSGKeyboardShared/Utilities/OSGDiag.swift | 2 + .../AIAgentSkillLayoutTests.swift | 199 ++++++++++ .../AIHintKeywordExtractorTests.swift | 10 + Scripts/generate_extract_todos_shortcut.py | 169 +++++++++ Tests/suite-manifest.json | 2 + project.yml | 3 + 33 files changed, 1628 insertions(+), 23 deletions(-) create mode 100644 OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut create mode 100644 OSGKeyboard/Services/AIAgentShortcutInstaller.swift create mode 100644 OSGKeyboard/Services/AIAgentShortcutRunner.swift create mode 100644 OSGKeyboard/Views/AIAgentSkillsView.swift create mode 100644 OSGKeyboardShared/Models/AIAgentSkillLayout.swift create mode 100644 OSGKeyboardShared/Services/AIAgentShortcutRun.swift create mode 100644 OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift create mode 100644 OSGKeyboardShared/Services/AITodoExtraction.swift create mode 100644 OSGKeyboardTests/AIAgentSkillLayoutTests.swift create mode 100644 Scripts/generate_extract_todos_shortcut.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 36062f7..1edcc90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。 +- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。 + +### Fixed +- **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. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。 + ### Changed - **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(日历、天气、新闻、股票、热搜、搜索)。 @@ -16,7 +23,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..d06719a 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/Info.plist @@ -69,6 +69,7 @@ zalo skype zoomus + shortcuts things todoist evernote diff --git a/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut b/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..0286763cfa763ed6fba34f80edb8e6196f402dd8 GIT binary patch literal 22481 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 CV%atT literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..0286763cfa763ed6fba34f80edb8e6196f402dd8 GIT binary patch literal 22481 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 CV%atT literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..25f2bb2daaeaa1d3fd27de35fdd8d22a486b1216 GIT binary patch literal 1948 zcmaJ>TWl0%6rPRF@XI+iqJf<8=4$?$Ftpb!OJK z5P6cANMd5527EBp#1~_Th7e8EXn4?9+X4xR;mHS$F(#Urn2_N4cMH2{5h%ua0EHX3eYN%G2{nu$>h3QcMe;@a6PQmL&I{MDXTbRa(xd(a~01I zi}i5Og`_ddx2-7$1*cj$gGF3&N83TQ23L;NQLxftZ(9sk(LFbWMh8Pw)g3Dc5Wv{k z$~j`YcC`*6jOKpNd;UKm7y7naZfk4EssaaXZ4G%12pXzbuh^b%a=TjM>Y?mhEX9LHYj#k6J$T6oLq#fE`3Sa~My4baXHc=LI7VdCgDaw`ud&;%srv&`;>~a=*## ziK|jtP?G{LYog988A0HaYE0#gbRr{af-a}x#!~mAkL?>Bvz$86w7Fp95J>CTXDlol zhPt$KSI=IP+hm2IRjgD2SfI_1SZ*1O1r4$ndv}kW75aO`R~P&C+YS#%;FaF-VAb^A^~}iKLLubMghhj4PYC><<2Lc>gS^-2MXWE`qO$ZZ1XtpP7yC?v-PwxXr!rsMR@wy}{eX;F3 z;!s^Jmwn4E*=JA#;$(~Ml~>I9!igr?%^(0src7?vY5;sxLsl3?K-u*JozFab@VP_J z1BnB^SFgc zO?n&psQ0A?h{gb{$Z8O+QprWLKC0So3Hdh;cMiVrV)jUOC<~JbZZWHSU;qKiRS<&u zum-y2Ah%@sc;VX;U`Jr?+JkvJbkIqdMW|A!Aa|tht_6@vTFeNBEc1zk4&y9{3a`a7 zabA=)Nd<^$K~@0bFxK}d=zDBg>Kn=BM^D_dE4(`kP$K2aqu5T6m>5K8;+BEJc1NH;`M%{bWCR zh|H1!IY*u&-zG1Sm&vQ-7vwkOHS&A%CV7kejr@b!Om$KHlt_(JQ&gF9D36+>-k{#0 z7N`%X%hVO>26dbIlU_q_q~r8S+N4W#m3HZQ`Yrlh`b+vd`WF2cv!3ZsX7nRCo}<`d>JbA|be`I@=L+-Ck}?ywP-WEr-bmDxCZfE{M@>;zk4FR*X2 V7uich<3~oCPvYg3=gNb9?;ka?eK-IB literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift new file mode 100644 index 0000000..8d973a5 --- /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 { + static let bundledResourceName = "OSGExtractTodos" + + @MainActor + static func openInstallPage(for skill: AIClipboardSkill) { + if let shareURL = skill.shortcutICloudURL, + let installURL = AIAgentShortcutRun.shortcutsInstallURL(from: shareURL) { + UIApplication.shared.open(installURL) + return + } + openBundledShortcut() + } + + @MainActor + private static func openBundledShortcut() { + guard let bundled = Bundle.main.url( + forResource: bundledResourceName, + withExtension: "shortcut" + ) else { return } + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).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..95d61f2 --- /dev/null +++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift @@ -0,0 +1,71 @@ +// AIAgentShortcutRunner.swift +// OSGKeyboard · Main App +// +// Consumes the keyboard's pending extract-todos payload and opens the +// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add +// x-callback URLs so Console can record success / error / cancel. + +import UIKit +import OSGKeyboardShared + +enum AIAgentShortcutRunner { + @MainActor + static func runPendingIfNeeded() { + AIAgentShortcutRun.trace("host.runPending begin") + guard let payload = AppGroupStore().consumePendingShortcutRun() else { return } + guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID), + let name = skill.shortcutName else { + AIAgentShortcutRun.trace( + "host.runPending skip unknownSkill=\(payload.skillID)" + ) + return + } + AIAgentShortcutRun.traceBody("host.titlesToShortcut", payload.joinedTitles) + guard let url = shortcutsURL(name: name, text: payload.joinedTitles) 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)" + ) + } + + 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/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift new file mode 100644 index 0000000..72884fb --- /dev/null +++ b/OSGKeyboard/Views/AIAgentSkillsView.swift @@ -0,0 +1,341 @@ +// 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 reorders that row. Export +// skills confirm a companion Shortcut before they can occupy a slot. + +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 showFullAlert = false + + private let columns = [ + GridItem(.flexible(), spacing: Spacing.sm), + GridItem(.flexible(), spacing: Spacing.sm), + ] + + var body: some View { + NavigationStack { + ScrollView { + CardPageContent(spacing: Spacing.xl) { + if !config.clipboardHistoryEnabled { + clipboardHistoryBanner + } + enabledSection + if !store.availableSkills.isEmpty { + availableSection + } + } + .tabBarScrollBottomPadding() + } + .background(palette.background) + .navigationTitle("skills.title") + .navigationBarTitleDisplayMode(.large) + } + .sheet(item: $viewingSkill) { skill in + SkillDetailSheet( + store: store, + skill: skill, + onDismiss: { viewingSkill = nil }, + onAddOrWarn: addOrWarn, + onConfirmInstall: confirmShortcutInstall + ) + } + .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 { + Text("skills.clipboardHistory.banner") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Spacing.md) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + 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 { + LazyVGrid(columns: columns, spacing: Spacing.sm) { + ForEach(store.enabledSkills) { skill in + skillCard(skill, showsEnabledBadge: true) + .onTapGesture { viewingSkill = skill } + .draggable(skill.id) { + skillCard(skill, showsEnabledBadge: true) + .frame(width: 160) + } + .dropDestination(for: String.self) { items, _ in + guard let dragged = items.first else { return false } + store.moveEnabled(id: dragged, onto: skill.id) + return true + } + } + } + } + } + } + + private var availableSection: some View { + CardSection("skills.available.section") { + LazyVGrid(columns: columns, spacing: Spacing.sm) { + ForEach(store.availableSkills) { skill in + Button { + viewingSkill = skill + } label: { + skillCard(skill, showsEnabledBadge: false) + } + .buttonStyle(.plain) + } + } + } + } + + private func skillCard(_ skill: AIClipboardSkill, showsEnabledBadge: Bool) -> some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack(spacing: Spacing.sm) { + Image(systemName: skill.systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(palette.accent) + Spacer(minLength: 0) + if showsEnabledBadge { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(palette.accent) + } + } + Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + Text(AppL10n.string(skill.descriptionKey, language: config.uiLanguage)) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .lineLimit(2) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading) + .padding(Spacing.md) + .background( + showsEnabledBadge ? palette.accentMuted : palette.surface, + in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke( + showsEnabledBadge ? palette.accent : palette.divider, + lineWidth: showsEnabledBadge ? 1.5 : 0.5 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + } + + 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 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 + + /// Content stack height; nav chrome is added for the detent. + @State private var contentHeight: CGFloat = 240 + private let navigationChrome: CGFloat = 56 + + var body: some View { + NavigationStack { + CardPageContent( + spacing: Spacing.md, + topPadding: Spacing.md, + bottomPadding: Spacing.lg + ) { + header + explanation + skillActions + } + .background(palette.background) + .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 + } + } + .navigationTitle("skills.detail.title") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("common.done", action: onDismiss) + } + } + } + .presentationDetents([.height(contentHeight)]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.resizes) + .animation(.easeInOut(duration: 0.2), value: contentHeight) + } + + private var explanation: some View { + Text(AppL10n.string(explanationKey, language: config.uiLanguage)) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .lineLimit(1) + .frame(maxWidth: .infinity, minHeight: 80, maxHeight: 80, alignment: .topLeading) + } + + private var explanationKey: String { + if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) { + return "skills.install.lead" + } + if skill.requiresShortcut, store.layout.isEnabled(skill.id) { + return "skills.action.turnOffHint" + } + return skill.descriptionKey + } + + 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(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)) + .font(TypeStyle.title3) + .foregroundStyle(palette.textPrimary) + if skill.isDefault { + Text("skills.badge.default") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + } + } + Spacer(minLength: 0) + } + } + + @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 { + let label = Text(titleKey) + .frame(maxWidth: .infinity, minHeight: 38) + if prominent { + Button(action: action) { label } + .buttonStyle(.borderedProminent) + .tint(palette.accent) + } else { + Button(action: action) { label } + .buttonStyle(.bordered) + } + } + + private func openShortcutInstall() { + AIAgentShortcutInstaller.openInstallPage(for: skill) + } +} diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index 91ce86e..5b72805 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,13 +11,14 @@ 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 .skills, .styles: return .menuBook // unused — these tabs use SF Symbols case .settings: return .settings } } @@ -25,6 +26,7 @@ enum AppTab: Int, CaseIterable { /// SF Symbol overrides shared with the Mac and iPad sidebars. var sfSymbol: String? { switch self { + case .skills: return "sparkles" case .styles: return "text.badge.star" default: return nil } @@ -33,6 +35,7 @@ enum AppTab: Int, CaseIterable { 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" } @@ -44,6 +47,7 @@ enum AppTab: Int, CaseIterable { var sidebarSystemImage: String { switch self { case .keyboard: return "house" + case .skills: return "sparkles" case .styles: return "text.badge.star" case .settings: return "gearshape" } @@ -110,7 +114,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/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/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/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index df445f2..efaeca5 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -427,8 +427,35 @@ "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.clipboardHistory.banner" = "Turn on Clipboard History in Settings to use these skills after you copy text."; +"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.install.lead" = "Add it, then confirm. Don’t rename it."; +"skills.install.openShortcuts" = "Add Shortcut"; +"skills.install.confirmAdded" = "I've added it"; + /* 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..891f7b8 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -426,8 +426,35 @@ "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.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。"; +"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.install.lead" = "添加后点「我已添加」,请勿改名。"; +"skills.install.openShortcuts" = "添加捷径"; +"skills.install.confirmAdded" = "我已添加"; + /* 润色风格包 */ "polishStyles.title" = "润色风格"; "polishStyles.add" = "添加风格"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 42f608b..9d13838 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 @@ -942,6 +947,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..6cbe42f 100644 --- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift +++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift @@ -70,15 +70,26 @@ final class AIKeyboardCoordinator { func submitClipboardSkill(_ skill: AIClipboardSkill) { guard canAcceptIdleSubmit else { return } enterIfNeeded() + state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil let instruction = AIClipboardSkillCatalog.instruction( for: skill, locale: AIHintLocaleResolver.packLocale(), translationTargetLocaleId: state.translationTargetLocaleId ) + let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)") + if let material { + AIAgentShortcutRun.traceBody("keyboard.clipboard", material) + } else { + AIAgentShortcutRun.trace("keyboard.clipboard missing") + } let resolution = AIClipboardPrompt.resolve( instruction: instruction, - material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + material: material ) + if case .materialUnavailable = resolution { + AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)") + } submitResolvedPrompt(resolution) } @@ -106,24 +117,30 @@ final class AIKeyboardCoordinator { private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) { guard case .ready(let prompt) = resolution else { // The clipboard window closed between rendering and this tap. + state.pendingClipboardSkillID = nil 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 { + state.pendingClipboardSkillID = nil + return + } let disposition = flow.submitAIQuestion( text: prompt, conversationID: conversationID ) if case .rejected(let rejection) = disposition { + state.pendingClipboardSkillID = nil state.aiSession.fail(message(for: rejection), utteranceID: nil) } } func cancel() { guard state.aiSession.isBusy else { return } + state.pendingClipboardSkillID = nil flow.cancelAIRecording() state.aiSession.cancelCurrentWork() } @@ -181,6 +198,7 @@ final class AIKeyboardCoordinator { } func receivePartialAnswer(_ draft: String, utteranceID: UUID) { + if isPendingExportSkill { return } state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID) } @@ -188,6 +206,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 +224,59 @@ final class AIKeyboardCoordinator { } func fail(_ message: String, utteranceID: UUID?) { + state.pendingClipboardSkillID = nil state.aiSession.fail(message, utteranceID: utteranceID) } private func endConversationIfNeeded() { + state.pendingClipboardSkillID = nil guard let conversationID = state.aiSession.conversationID else { return } flow.endAIConversation(conversationID) } + private var isPendingExportSkill: Bool { + guard let id = state.pendingClipboardSkillID else { return false } + return AIClipboardSkillCatalog.skill(id: id)?.kind == .export + } + + /// Parse extract-todos. Empty → in-keyboard tip, stay in the host app. + /// Titles → hand off to the host to run the companion Shortcut. + private func finishExportSkill(answer: String) { + let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + let items = AITodoExtraction.items(from: answer, sourceClipboard: source) + 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 + let skillID = state.pendingClipboardSkillID + state.pendingClipboardSkillID = nil + 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("keyboard.ai.skill.noTodos") + return + } + guard let skillID, + AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else { + AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")") + state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") + return + } + AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)") + state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut") + 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/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift index fb43f32..16bb6e9 100644 --- a/OSGKeyboardExt/Views/AIKeyboardView.swift +++ b/OSGKeyboardExt/Views/AIKeyboardView.swift @@ -55,6 +55,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 +183,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,8 +236,9 @@ struct AIKeyboardView: View { } private var clipboardSkillRow: some View { - HStack(spacing: Spacing.lg) { - ForEach(AIClipboardSkillCatalog.visible()) { skill in + let skills = visibleClipboardSkills + let row = HStack(spacing: Spacing.lg) { + ForEach(skills) { skill in Button { state.submitAIClipboardSkill(skill) } label: { @@ -233,6 +258,15 @@ struct AIKeyboardView: View { .accessibilityLabel(Text(clipboardSkillTitle(skill))) } } + return Group { + if skills.count > 4 { + ScrollView(.horizontal, showsIndicators: false) { + row.padding(.horizontal, Spacing.md) + } + } else { + row + } + } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -250,12 +284,17 @@ struct AIKeyboardView: View { /// Copy-then-30s window: skill chips replace the rotating hint. private var showsClipboardSkills: Bool { 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] { + AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs) + } + /// 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 +471,9 @@ struct AIKeyboardView: View { } return state.aiSession.transcript case .generating: + if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID { + return ExtL10n.string("keyboard.ai.skill.extracting") + } if let draft = state.aiSession.draftAnswerText, !draft.isEmpty { return ExtL10n.string("keyboard.ai.generating") } diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 39367d3..46f10dc 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -297,3 +297,9 @@ "keyboard.ai.skill.reply" = "Reply"; "keyboard.ai.skill.summarize" = "Summarize"; "keyboard.ai.skill.translate" = "Translate"; +"keyboard.ai.skill.extractTodos" = "Tasks"; +"keyboard.ai.skill.noTodos" = "No tasks in the clipboard"; +"keyboard.ai.skill.extracting" = "Finding tasks…"; +"keyboard.ai.skill.runningShortcut" = "Running Shortcut…"; +"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..3280535 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -297,3 +297,9 @@ "keyboard.ai.skill.reply" = "回复"; "keyboard.ai.skill.summarize" = "总结"; "keyboard.ai.skill.translate" = "翻译"; +"keyboard.ai.skill.extractTodos" = "待办"; +"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项"; +"keyboard.ai.skill.extracting" = "正在提取待办…"; +"keyboard.ai.skill.runningShortcut" = "正在运行捷径…"; +"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装"; +"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径"; diff --git a/OSGKeyboardShared/Models/AIAgentSkillLayout.swift b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift new file mode 100644 index 0000000..2194185 --- /dev/null +++ b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift @@ -0,0 +1,78 @@ +// 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() -> AIAgentSkillLayout { + let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.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/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index 5e63e66..044dd28 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -69,6 +69,8 @@ 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" } // MARK: - Stored fields @@ -113,8 +115,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/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift new file mode 100644 index 0000000..206e4c8 --- /dev/null +++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift @@ -0,0 +1,128 @@ +// 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" + /// 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") + } + + /// 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..be6d884 --- /dev/null +++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift @@ -0,0 +1,110 @@ +// 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 + + private let defaults: UserDefaults? + private let persist: (AIAgentSkillLayout) -> Void + private let load: () -> AIAgentSkillLayout + + public init(defaults: UserDefaults? = nil) { + if let defaults { + self.defaults = defaults + self.load = { AppGroupStore(defaults: defaults).agentSkillLayout } + self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) } + } else { + self.defaults = nil + self.load = { AppGroupStore().agentSkillLayout } + self.persist = { AppGroupStore().setAgentSkillLayout($0) } + } + self.layout = self.load() + } + + public func reload() { + layout = load() + } + + public var enabledSkills: [AIClipboardSkill] { + AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs) + } + + public var availableSkills: [AIClipboardSkill] { + AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) } + } + + @discardableResult + public func enable(_ id: String) -> AIAgentSkillEnableResult { + let current = layout.sanitized() + guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown } + if current.isEnabled(id) { return .alreadyEnabled } + if skill.requiresShortcut, !current.hasConfirmedShortcut(id) { + return .needsShortcut + } + if current.isFull { return .full } + commit( + 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() + commit( + 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), skill.requiresShortcut else { + return .unknown + } + var current = layout.sanitized() + if !current.confirmedShortcutIDs.contains(id) { + current.confirmedShortcutIDs.append(id) + } + commit(current) + return enable(id) + } + + public func moveEnabled(id draggedID: String, onto targetID: String) { + var ids = layout.sanitized().enabledIDs + guard let from = ids.firstIndex(of: draggedID), + let to = ids.firstIndex(of: targetID), + from != to else { return } + ids.move( + fromOffsets: IndexSet(integer: from), + toOffset: to > from ? to + 1 : to + ) + commit( + AIAgentSkillLayout( + enabledIDs: ids, + confirmedShortcutIDs: layout.confirmedShortcutIDs + ) + ) + } + + private func commit(_ layout: AIAgentSkillLayout) { + persist(layout) + self.layout = load() + } +} diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift index f7d0b56..3001289 100644 --- a/OSGKeyboardShared/Services/AIClipboardSkill.swift +++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift @@ -2,20 +2,57 @@ // 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 a companion Shortcut after the model runs. 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? - public init(id: String, systemImage: String, titleKey: String) { + public var requiresShortcut: Bool { kind == .export } + + public init( + id: String, + systemImage: String, + titleKey: String, + cardTitleKey: String, + descriptionKey: String, + kind: AIClipboardSkillKind, + isDefault: Bool, + shortcutName: String? = nil, + shortcutICloudURL: URL? = nil + ) { 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 } } @@ -23,31 +60,68 @@ 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 = "OSG · 提取待办" + public static let extractTodosShortcutICloudURL = URL( + string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81" + )! - /// Default set, in display order. Future skills append here. - public static let builtIn: [AIClipboardSkill] = [ + /// 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, + shortcutICloudURL: extractTodosShortcutICloudURL ), ] - /// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list. + /// Legacy alias: the three default transform skills used to be the whole list. + public static let builtIn: [AIClipboardSkill] = catalog + + public static func skill(id: String) -> AIClipboardSkill? { + catalog.first { $0.id == id } + } + + /// `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) -> [AIClipboardSkill] { - guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn } - let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) }) - return enabledIDs.compactMap { byID[$0] } + let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs + guard !ids.isEmpty else { return [] } + let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) }) + return ids.compactMap { byID[$0] } } public static func instruction( @@ -102,6 +176,18 @@ 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. + """ default: return zh ? "请根据剪贴板内容完成用户选择的操作。" 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..80b2ab4 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -97,6 +97,10 @@ 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) + } // MARK: - Writes @@ -199,6 +203,59 @@ public struct AppGroupStore: @unchecked Sendable { mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled } } + public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) { + do { + let data = try JSONEncoder().encode(layout.sanitized()) + defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout) + } catch { + OSGLog.config.warning("agentSkillLayout 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) -> AIAgentSkillLayout { + guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else { + return .default + } + do { + return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized() + } catch { + OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)") + return .default + } + } + public var hasCompletedOnboarding: Bool { get { configuration.hasCompletedOnboarding } set { setHasCompletedOnboarding(newValue) } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index ee49223..6e923ee 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -140,6 +140,12 @@ 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? + /// 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. @@ -239,8 +245,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/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/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift new file mode 100644 index 0000000..1b46f05 --- /dev/null +++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift @@ -0,0 +1,199 @@ +// 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.. 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": "OSG · 提取待办", + "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/Tests/suite-manifest.json b/Tests/suite-manifest.json index dd21bd0..5486e70 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -132,6 +132,8 @@ "OSGKeyboardTests/CursorNavigationTests", "OSGKeyboardTests/KeyboardTranslationConfigProtectionTests", "OSGKeyboardTests/AIHintPoolTests", + "OSGKeyboardTests/AIHintKeywordExtractorTests", + "OSGKeyboardTests/AIAgentSkillLayoutTests", "OSGKeyboardTests/ClipboardHistoryPolicyTests", "OSGKeyboardTests/ClipboardHistoryStoreTests" ] diff --git a/project.yml b/project.yml index 00a0157..48c18a4 100644 --- a/project.yml +++ b/project.yml @@ -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 @@ -120,6 +121,7 @@ targets: - path: OSGKeyboard/Resources/Typing/Rime/manifest.json - path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin - path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json + - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut - path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json - path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt - path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt @@ -194,6 +196,7 @@ targets: - zalo - skype - zoomus + - shortcuts - things - todoist - evernote From 0a60006d2d6489c2a86fec057c757a2415773fd2 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:01:43 +0800 Subject: [PATCH 2/3] feat(keyboard): add custom skills plus events and navigate Shortcuts Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69. --- .gitignore | 1 + AGENTS.md | 2 +- CHANGELOG.md | 9 + OSGKeyboard/Info.plist | 4 + OSGKeyboard/OSGKeyboardApp.swift | 2 + .../Shortcuts/OSGExtractEvents.shortcut | Bin 0 -> 23409 bytes .../OSGExtractEvents.unsigned.shortcut | Bin 0 -> 3288 bytes .../Resources/Shortcuts/OSGNavigate.shortcut | Bin 0 -> 22013 bytes .../Shortcuts/OSGNavigate.unsigned.shortcut | Bin 0 -> 1366 bytes .../Services/AIAgentShortcutInstaller.swift | 18 +- .../Services/AIAgentShortcutRunner.swift | 30 +- OSGKeyboard/Services/FlowSessionManager.swift | 10 +- OSGKeyboard/Views/AIAgentSkillsView.swift | 9063 ++++++++++++++++- .../AIClipboardSkillLayoutDemoView.swift | 136 + OSGKeyboard/Views/CatalogCardChrome.swift | 31 + OSGKeyboard/Views/PolishStylesView.swift | 18 +- OSGKeyboard/en.lproj/Localizable.strings | 39 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 39 +- .../Services/AIKeyboardCoordinator.swift | 46 +- .../Services/KeyboardFlowCoordinator.swift | 10 +- OSGKeyboardExt/Views/AIKeyboardView.swift | 102 +- OSGKeyboardExt/en.lproj/Keyboard.strings | 10 + OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 10 + OSGKeyboardMac/MacPolishStylesView.swift | 16 +- .../Models/AIAgentSkillLayout.swift | 6 +- OSGKeyboardShared/Models/AIUserSkill.swift | 177 + .../Models/AppGroupConfiguration.swift | 2 + .../Models/FlowSession/FlowCommand.swift | 4 + .../Models/FlowUtteranceRequest.swift | 12 +- .../Services/AIAddressExtraction.swift | 148 + .../Services/AIAgentSkillLayoutStore.swift | 123 +- .../Services/AIClipboardSkill.swift | 154 +- .../Services/AIEventExtraction.swift | 279 + .../Services/AIGenericSkillExport.swift | 25 + .../Services/AIMapNavigation.swift | 126 + .../Services/AIModeLLMClientFactory.swift | 9 +- .../Services/AIQuestionService.swift | 6 +- .../Services/AIShortcutShareLink.swift | 94 + .../Services/AppGroupStore.swift | 47 +- .../AIAddressExtractionTests.swift | 180 + .../AIAgentSkillLayoutTests.swift | 63 +- OSGKeyboardTests/AIEventExtractionTests.swift | 143 + .../AIHintKeywordExtractorTests.swift | 20 + OSGKeyboardTests/AIModeLLMClientTests.swift | 12 + OSGKeyboardTests/AIUserSkillTests.swift | 189 + OSGKeyboardTests/FlowSessionBridgeTests.swift | 41 + Scripts/generate_extract_events_shortcut.py | 272 + Scripts/generate_navigate_shortcut.py | 113 + Tests/suite-manifest.json | 4 + project.yml | 18 +- 50 files changed, 11612 insertions(+), 251 deletions(-) create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut create mode 100644 OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift create mode 100644 OSGKeyboard/Views/CatalogCardChrome.swift create mode 100644 OSGKeyboardShared/Models/AIUserSkill.swift create mode 100644 OSGKeyboardShared/Services/AIAddressExtraction.swift create mode 100644 OSGKeyboardShared/Services/AIEventExtraction.swift create mode 100644 OSGKeyboardShared/Services/AIGenericSkillExport.swift create mode 100644 OSGKeyboardShared/Services/AIMapNavigation.swift create mode 100644 OSGKeyboardShared/Services/AIShortcutShareLink.swift create mode 100644 OSGKeyboardTests/AIAddressExtractionTests.swift create mode 100644 OSGKeyboardTests/AIEventExtractionTests.swift create mode 100644 OSGKeyboardTests/AIUserSkillTests.swift create mode 100644 Scripts/generate_extract_events_shortcut.py create mode 100644 Scripts/generate_navigate_shortcut.py 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..45a767c 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 69)**. Releases use stable SemVer: | Field | File | Rule | |-------|------|------| diff --git a/CHANGELOG.md b/CHANGELOG.md index 1edcc90..902cb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。 - **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。 +- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。 +- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。 +- **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 - **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 +- **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 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。 diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist index d06719a..78992f7 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/Info.plist @@ -70,6 +70,10 @@ 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/OSGExtractEvents.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..e1f601f9c1c817461d6950f8506814102d91db0b GIT binary patch literal 23409 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 literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..a467a09a7cace7eb5dcfbc4164009efe95098d44 GIT binary patch literal 3288 zcmaJ@dz2I96;Ec8$xLW#c dE-Y9TzU^c(c~wy73EM6UY<4#+kByULb~kKZWF{<& zi1EdjCo0 ze)qfI{r&F!?){R*Y{oEI_IFN#bh#-H&3MOs=Chx>;lyjhTYvt`U%mDAJEt#PO!&sf z)p()Q-;*s22q`mD$QudYja8kL&FFcvP0#9@p#sS~wBTDow5C_|| ze6QYVSgfC!F|+eNc5Z`z!H`fenY?ZQX7*OV<)3|K26ryN2OC3EriS4Fp|`iB<QWK0K6z$pryGeTuJyQ+q7Dl5w0c~XGc9hxo*K7a~3v#>B~U!n4MjWSCf!j5$}Jc zdN_}QAc$xEJQ9OS2>AmdC&1A%8{n4?b=tcjhDA65t&o7lX9JB(mbS%KUg<8l!Yj+J ze647+NNm9E0%GdugbsB*uHn*Tz@(>C$l+!Rksr2|JFx->q=pg4O93$|3yPl)Lf9*9>Q*gm4p)56RoGR7U^ZCJ zad?`2a59zdO(gF>+ncDm4xumz;<-Qw$zdUa0zsLV1VvQ2Ump6a>m97(r@O5>PO#h7^f~dlQa;v$E}Mt>w%G&IZ?iLdC-{NM~^8(6Sfqth;Y$ z-uw;s4?J+sS0D#a-u9B!mal$kS^GBGJ)s&?6bT@R4~C+M6=V^GB5YWd{hShvM5|^3 z5iM)* z!X+VOv$8g5_0w9lteJS&)m>}cQb<|N)*2PH<~7H_9DzxH&7!H5OziMur?h9xtiB35 zIYb%n)AMcRJY6MBL=p&s#EOWMR2e1$2Yn>KvtdDGfThRr#oPuL^V?RFc%ozgThr>d zWA#&@e%exhr)#;T-htKM1NAeOy0W_8;&yP?S!j40p6#w4EFl>L@sX&6U?wRjDh33O z<%9l+DB*NG_ghT%^ETVxAKtoW|L~7SfNtnJwZN5p+J=2)yvaxbL3~sI?}}eVg2amf ze=x*FB^e_B0S<95gt*TN@dYdW)9Mdk^^2hXp{0Il4Cl*O{bNwSVyO>~;d~XV4}tn8 zminhxa2~O69__8-RFDdSctKQ9P~})8%0Y?eLg9cQD}Xb43~#yXc+D0Su^@;SB^8C( zfFDUpNDcQN$v3Wf*x(1)g6v?iueF>@;s?i+>T3kA z#c-iWg>0cT{x8!eTz>Ga%avWH-n3`EHNW91UU`pMWVdBfeR$5+=*N=}#iA^`+OgOX zb1ZN)I7S=|snf9w`k&U18$C06_FOw|+RJNSHZ>2n|9g4~M!#ApS+niT`BPo^zF6#AM9%T3<0Z#2$9qnPbB1%FGww8;+nsxzhnz>9?>hfY)DhDOmIx9Hi4J0j z*hp+5b`ZOW5#lIuoOpvcMVuynPrOfDB0ePR$=PI>oJZbG_K-!gOs*$4kPnmF$!Ey} zY>(9YpEU7E@~h30(F3TnL1CsPyNH=_4qx@JPA+Qlkt>1hG(7UY0rMo zG0#QM-)V|&r03HyTBDQn7Wxr-nBGP|NmuBd^z-yS`T#vbze*pWPtdQ^Z`1G4=jeAC zg7Gl*49~1!?qzhQk6FW%m;q*pd4So>Y+)W_9%r6lo?$A?bIe|5KXZ^7VGc8|F&{Ah zWG;E9duMv*cv)|Yx7WMcTksaWhIfm1r+2q^kN1f8g!iO()cYUrN8_k*9)~4_)Bbh* LdhB=XXB_=Mp*}bt literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..75d2304d0476e02b09fa039211bfead360f62a71 GIT binary patch literal 22013 zcmeI4Wl&t-y5?JOcbCQ;8i(NS4#AzqoyLMoaCdhN1QIj}1W1tJ!66VNI3xs@AYsTk z=YP*RckWEhR87tO!mjGt_3pj)T5JE-^Q_&U9w|8~ZotElj{*Q#xjQ>}!J*JeAjlkt zSjWN6)xp(H2IdKOuywGugu`U)Egf9T!3c;NxL{OhI2aKc4yrE!gMc6q3X^?*BQqxh zI}{BGg*8fXTnqsWf_ic@Lg|sfdI(?)N-1}DXBb4s-wO_Nfhf7cVRoLDa0fS6h>R43 z@2_^~f3^GPsDGMBdBg49JRRWv-1t!3KmDO$p#8l+C0A=sZgeQ>pNmNNAUz!@6)`4{ z091&Zmz$TLAIfd?PzpkYp~BpP`~tj!Mo=|w7AVu-=LF%kw1L?Q2?|?s3&HrfZEY=K zRswuNHo}(t0)j9oFYo`saE4HPB;>!nFbMdMHwOce!O{Q(sd%AptktR2r8nFRd!(-l5yT6W>_@x=_ScJ0M6u+3`!@Dz^`R!VUW}2B!s5~5~ z0SyPLKrtR>ONkEz-XnmZ04VjJiVlGUN)$@)OyQkS93j0Tt^8P5G8ugUADGnvDusl? zh6F@JL&fZ=QJ}_s0h^?C^#Max@`p>$1K^z`L;OT1V{HOKbS5wo|&Bogr?gg=Q zwc&bb`}eEvo^IAKFE5xECzSHfys!|65D0ijh%a9M=IKd$@&aa9rrn7cWNrk-`g0rm z52*+BA%{Y-|6}5KKm-sX;9tcFRscX{K@f&s^A-XuwyR9CuappXms-Oj=+N7w(O@}d z0WtA_PDc>LFm5Uj>+bj>#U|>dJmP&)M(vH5Vxr##UZEu-nJo3G1}By#H?K0VYG&wGilt%t^Ojp20c__m^_j(8tynb$g{dL{( zJE;N}=b=aiumy-O8)(G%vZR28A#Q6M$xS2Bs66Zv8g}-u)DKJlHguV_c|Cfv(eDVY;1QqzJe((wF|K2BmjN|#Rc-EG7yimbE zBU2+lp`1|m64nytD8_$0Z0+g%FCP1w+x~ip^WomVi21+t@xO@~hy=w!Li>jckwCcr zNzGue9RO+rf)ZlXCJi)ZIXG1pFPqGO4V@dbXDkNdeb(Ch%Z^b4*YY-;UT*|)_c1-I zPKv#&^94aIc_GIv_rIcf8dwHx#C!s@DyBeOK3x{HsmHTVyuY@r53pwa9zgx7tT9V} zbUWPipmv1^rOQ0*Ohf-rJ4gR*%v8Th=6|2sANH(=eNKp57%K2b?ZW?3{{OR`&%+A+ z$Cm(+Eerub3$_9vYNqSZsq|Hg85m$xo@2ACAuHf}FJ@Auaelg3f zc%p^IT`mok{1++xms059%=fim`$VqZ1pCXw_isl7M&0nwSA70CWHYfCVgoME~b62mk;A{{0L<`p5Yn zCloiR901falHID%lld zxtM#2)V}ZmSP?>yC+4|qR{lBQf0ta*B@i1vVQ1=p)wT<1kw%P(N&n8b{F!&&uLZYJgV?4^A@lk3 zBE2&ABB@40z&%5%!cdu1z}1V8c*MU?=1%XgN#Oth4>Vw)zv_QK|8i~kwrbutqXJUz zoDjtnl29>|W2L8e_|qQ}0svr{)2K}BkRRyRJ%`%Os>$mQ5`IY~cYn0> zO*x+kXzlUuy*^ft1Re=I5_lx=NZ^scBY{T(j|3hGJQ8>$@JQg1z$1Z20*?e92|N;b zB=AV!k-#H?M*@!o9tk`WcqH&h;E}*1fky(51Re=I5_lx=NZ^sce^#09p! zU=vO$T&|ORPRNE7OLp}RO4JC;>NTE^?WwO)<$7oPR`92lix#HU(HHzSo6-t1!D>*mxbK*9ip}6V-X+PeP4+Ybw85BYJ9NyV($}8b@Rqfmn5z@jK%r)Ga zIG4gn=`Stm<2rtZqlGv>G4hyXGVi?ODo2BA-6a#vJOVx2!#o|iB&i6DBb-{^e} z4P-U4;t@v>q#r;~1*3mnG|4OV(HV;~c$Qy0E2FWw7vWU1jO1T9aF1evgUI8#rvaHn z6Aec-7!Ci*X??muUShWHEFlqxzOfcoOQCsgYY?V+A8YoBI${R@D(ThH`?-7c`i4&Y zbGh&R)tB-lWGH~{n{rFvuQ%6=s<#mZuYY6OBeH9oc%6df<@DcVSlTm}J87)0IOy+Y ze4*i_!RH^ZpVK4QxKpi@5yqJ;*rsbq8u<|#6CxaD0sIXAMp|e2w%uF8iZOw;QATZi zd5HHLezC$uR$#J&a3W$hR@v>^K=9yiLo*iD;d4iek(F56ev~#?tQ%aivBn{Selkyo*i=I0Cd9dM6XAJRW~zL+PaebSpAy<8&V~8TU}^Hn^gwg zUY(p;-pN0w`4EM_2tz*s?EAvGab8>Xad<9K6Ko~M>RhUap9j_-6uReX-bS`saWO@bSQB=<_ zHn-S~rN5YM5*lERzWQ7q(^w%Dte?|&fTJA{ko)=jwm(nvQA4A-SL3gQT&r-qkZ4h5 zMPzmwO{TX9!E>Q?g*&3ZBW;|n5~V08)IMLR6pS}NkM82;U1(1P<_?GPjT|vn?1na2 z8}biVx6@*TFy7eEEi=MPr4f(R<_n&1bC=(fXB_^BDr_z2WC+b8-HuC^N#E)aXnGZ5 zFZC=++Lrxy$5cq0J)!ql*l&)OrQym?io6*zpFU4#T`d?*U~2cXP1xt8;=c7~$@fM_ zjl(R7-ehvPi)ISP+#}R4*%aPX#+XZ>Kv?%R9!K5^w=v6!!d}9reK8pOp;#7j#^$2h zrTI;_4QC65SNxN!E#C`F2hv6VL7K4Ih;+ZEmXE#lUqpI9>jEZ`x5U5ux8w~yqlvN) z>P6btg0jjhdeY4}#=L|rrhY7g9EtE4Z^%kon-H_^W9Q)X4&W!xeqI_+VvUlM5Z=DV z^JiS*Zp}_f0VHQda(wq*Vh^WydMR1CbF0-=iB-1IAd#8P73@MS)U^E!!nZxVzJL~e zWk1?O&FeA)I$TAEL6_8?$luPfp&i;$Aa*`0#WxRFvx~6M6xFF;jCn?9%iY^@lQweR=aA9;Nv2Oc z^$7}L=b;&e3$eDr%eGAlyU9-R*Badv6uQ5OJIQ%OXbF-p*`ssI0;LQ@4IOlQ7*nb@ zK;g6Oo7jxzuf{$?io)e!yz{Dac#`-V-|5q|5)N3zaKXZ5UHA@bd` zKyEpAFf7vd<`Lf=CzgJYJpoRW9MtYBcn3GvtzAZUf0b9R>P>Jeft21f6{(YJHzZCC z41BwLxsBcZU8B8yFHuazfX4c?uk>Bb7HQM7u_UB#jd!%q<>I5P)y1q4ZX~uFE!$G5 zUqI+0DBedI6MuOBJ7Uzcd4f<>Za{KPsH7nO0;KW^ch`P}_quW~rhuq!TTjrgT6wJN zC3a?IDL>~|Q}5~HEjJf2_B_370e-5u=~oj?=ksv+J&eqe1L~TM)sV1(h=IVn^{-=& zSh6fqnhIAc>@{mGyaQ_#Xbo5hC@)9R(#GJ_-xr<>l%%(JDJ&4eotV{%m{2tm2=zF> zmuzO_C0@X(be?TqXYd880?DM0CB_Umcb@pmNs-fZ2oxE9##CM!O)6QL_>x3BZsjF; z=r=}Ox=ZXIOr=bjkG+4~AYuAcFZ3{qqpSD+VD27GtxI|0th5VWw~+_~42@9hDk6F$ z6y^0BN^!Cu*Vsp`9ru0l}QdNhyjRq2Q! zM1@&VKRx{1B=?1Ludftj#_`9SK|$ECx|?*UY<*IJM96bf8nZ8YxvG3)DDX+^rOmT5 z<2_k4vIcxEX*7+d&adNfOA%V=oM~Q@u9?g&y<=0+klGexe-I#y)g|VFLVjF9jzTm_ znkUyUjtAR)fnn^&D=#1w*lx@5`Nwd%vloVlj%XW(I$FKHuZDWRH<;3h;-(|%$wsMe zSKmTsKPI1XQHO>MZ)SLEIiYt(@ml#+@UdK*o~9WuR{jV)cfAw&=n7ELtqVzcr&cZ3 z8e(Y{<7~c2*8`9HZsVepPgxut1Qw7Se)7bHCw1Q&&d1b75E$VV7stgpf*m)0kQ%Oh z4VAoTo}u)ejSWj+^1osfI!(%S)vuwN1`TTaapg2FtgX zgXB9y#(96FO&94?&FUS#CcgU5!ujpKevK9f`(XA=d^Fs9d&o0pv$ZjC3B@L8w1T68ihg=%Yl(=6RBnR8H8^77^f&Jt=JHcPlz{|Ma)CYXT*7(GMhQtZVRt& z8!&CA>8En(&dxqzCtSXi#hAjJcbEFiKx4vgd%1?Rsq1{xOkp@MWo^-Up^KKYoDHFF z5^;_{k?bfAZl`8wGBgMfw_KWHi=8L~yQc_z;>EaZEW~#mjs)4fyVRC@b?QCI;n5Uj z9_pWtt$UoSm6Nd-N@M(V@A!?8 z7k$9kI%yi3)wS}uhqNOl=0e0Z&P3R3W{F79BZxQq%W4x(H$gL}t>R(ljZK%Ce1!m1 zEmAbQnU?|_i_~l^f$zLMNpxfeU7qX(9&oRCx^^M841D--IutxAoVY@uVbQ_c;qIxaWyqgq*ZpklGGX?#art8|zXs^&3-rXu}=LgB$b6YT?q zxK0298u{V5`kg39tXzwL{DW;24=+-T#th79b-zSk3BRUk52mHq4oN%?aS;*>8u=2> zohupl`Wy|kU-)`mgb64J+k1X+V4i}`IJl`w7K!kNs&HxN8`q1;#z0LfOeamNy}fWp zuicK?{2k_<(}lRVDe-`@)-@{&W$c7VGcp~(Nn7tlok5d$EO;?f&w0`Q(pz!kn*sV& z|CN@v%)V9M%GEUj%mJjA8xc#X&7?Dwt!37lN(oV{dESJi$$qGbv2pNuHl>QAl&hJA zBGf=K-=UD%lu6cIe7HyNY1!Ma`TbG}@1}3=UX}CyR`RQ=oaZ~v=bb>DFX`bmV90Nd z($<=ma{7`Q{W3ngm!2uFM}%FZ7C%hCca4S%(v+Bwr`X=4n+F-aRrj^{Q)eTUTp)>8Z)iy1C;-l9k|f?;7Tg zes)==#Q?U0w!QZ>F*7T7DL$5)H>PL*b50)?4?TR!8uLN>0+nj*S4(vFo>(WDoB~m4 zKhJ_GBW;XKIm|%q@RhR7PbLEIWZlWX!z{YUOf45(HR611O;KR*(pU|xEN;JD2`3y3 zyit{xipLtxaBU;83IibH&zEs(XL!X>hUbiv3L{QDestD9eeG~b_x!#p&so$xzyDFm zxA5tQ7YJKFzYi9HgFEsfo8rlh-@Jc{6>%XpVERIv_+fO4ISdG_}sA@MP#S&CrNR%^5J z^x+d3!EmJGPk!OBudYQ9>ZL*F-owLcfq|5rA;RkS0&dlsdy;H5yS`htg?Ma+qkcNZzmOP$3s;wUzInsiS>e+P zbL3@J7X&BLgW_>%BF#N-Y(Lw&Hdo{83bq{Em99%1h$gK*tx!}Rz3d?C*LkztjIegT zS?Q;L{O0qc#pb7#}!!ulxTokyz6*H`X?u``8ucDFMWyV)uW?>XIgraSoxl#2 z-BGBDD>XA4`*F)ICl)E>r`l`BpF1}x4g4{_J8HXswI`Rp{$7jAi#HBzOq=~fIOHJu z73HsqS=Aaty&3%Hjs+asxqjWW`1a(dxn)4(yw`5tw`y(nH@}U9EXU~iqjcqd9{#@d zCoJCmHaVMd&CgsNrh$%YN-+-<#I!$rN4d&PMIO5@_vY)Z7C9NYL6LEWSLO$C<<^Nt zEgy?a0XbmcwFI+pr2>@`#TyZG!5=j|UzxSWXivHVz0fHPb#>GTycKsAtou6OThY$! z0=4+JO7T@_IHXNqkZn-&^`0jtx6lU2W8%!h^*>7n>7Rof+ME_4;S|gFMxBGnEgN=f zt&>lqKRXIY2F1w-NNc_{!Pge-`IZZ}^lHvWsjk}w z*g}IQ3fgAxUs{*w=QR{-S53U47kS?#^p%r(jI3uYPVg2h?P&lNUstf)+8de7_tX@| z;v=Ny7JV(pfFSm*SYD;!+o9ltWJ7>mOpKrOlOQ1va>`Jj&sp)kL8Dg$_0=8xFx) z)@gJSSPD9bKTecQ=ir2L%$z39D8e-1*j@;?uS=roc7IB2V*|`pL^?$B$$5tJ<&tmC z`EXNNr(GD{BZ)T;j=DM6J73rYea(A2{Bec^MPLlop$N5$nkD}JJY2WOP(eA5A4{P$K={R&G}ZpV}OJj6d7S5x6^){2Hc zHTy((%16;!Wc+&mM#2aFWHi*n-U^5Kkb-U2xrX#N^v*sGXsY~90DAjwGCI^gmA z_}rwC?Z98VH|ogp-SLrYl5XGP=2((t7+D?#+=G1drzCE6z?hQ*4^rw6ucAUm6hGi{ z?{mv0iD$efi5bz39v3uaAJ{&eD5T$R3BNrL>`9b5VBzcNZN~82lDKDFAUH*o^kud0 zwrVqs6D(ZiB9JullBqhsS~?880dMKf)Pzh^;jhJ>MEGuhC!A-+rRpVa!-jq?p0O77 zGEg&#KZ)dv!XOF@#y9$jl7x)7`VGW6EUYGzWh;jHvDsXAR<2aO-S%4oicwnc_cIGO zx8gYDHWqEY)7GhyzGC|yx_#ucTeQgiVw`2Q7L)Z^m+teM8LL=rd=X5IG3o@48KImX zXr<^*vD{?LyfQF1MAkcujpKP>ay#l>)I@RDE_64Mb`!fk(AXK~si1;LKZ^Q$c9G?d z36AIbFW-%~a`R+9NA`JRmv1(X-_BZ=cAxoE%H~`^c$NO^>(9TQ7JbE}5rOFnR5iH) zpJSQq>pZ)Tvb z22!D!PH;LkJ_=s5Bdx6TO&nD~PTs2}cbN&pel_1?VyPgQb1x$fGVbcT+N-?{H6SpZ5c*b1T+sN&XT=*kra#}H4Ps(n+Y%Dqo1y5}94{Jpk*a-jzI3Uxv$$BjFmayU|G3Qaq&-{TUDD8xt;83!N1bd5vj*WSMJ5dJ|lcpTC1!utb|6r0WH7=bUqHt)UBO50Px$O+Xo&@w*8 z)Ig}dR<5e_WNp|#%L(~Z8)9$xK?~pY>J9$UeQI6|c(kNTfdyp)wP(E51edypsjqV; z$|9-`p@vs6cakw>s*==N>>kKIWaV(+@KHDA=yiN^ zy8|NT>g~ts10U#Qhk}KlESY{~D}|5P<*T`iQ;CI_DK1-$Vi#VveUX{Sdi$>RpcQKy zM-(q(N@J;a&9>OZvO`6L~<-SQ0ym^|l z3s|aRQ0I3N@30uq?A32BfE?O+cz!xX_us-;II$bBD#yZT{B1?oFm`cJai24Z_XL~# zx_jB3ST#0(IiT&Hm_|B6(jF1^%frR+%Nd^aIIi7yylxJc^`1NO-mcG4RG>98NP|4A z72mf)5n?G3%iBso3wPCiJUev-9c1( zoTeBfDfs;Ba5s-iL*~-!Z48|h@(t6r4&VGd7o#_>KMtrd{Zdr>oJ~OO(~LSt4>HDr z#xl0hS8h4%?k{-*<)%?lyP>wazMZ{S`(!Rucl|X6i{B$%u{4=mI+@L!t1{4|R|YLX zGZ>+;^0(q87$B-+a*e#6m!=+S#Eu0Rft3TybmknxqbKR~zmQ^lqI}4gFoJamSWIna zxeXAdTJy{6&2?Gh*D#_r>CKLL?#?DRR?T^Nuvd zjDbVkm`UBwnD5b)qq)p0c`7#rDf8b9ntCLz8nCP?dS{U{YrU9dUF)$BRY6vNYjkGN zdG!o9sH^y~YWDfUsg?GDO=@5CIc|jsd=LLghPISC>EZ%PJ-q=*#9!le(yzsU(Kd{-uo! z4;Kze>E+lnEQnk-1%hz7;@C0=LQoO*_Z+l9rzp+s$)dBXLrM-VCD*c7 z?GYd2L=#YhJ#EnGesE40^uVPDy=V+AG5x-UbMecmS1T=YToy`2uI^f~UK6X19bGHY zm1-EHVbD3hCd}4m;`^OS+xq>g<_+;)XB#$MrCmuyytuph zZ10vDbq?z%%7pbt*1@?P*UN(V664qFnqm}zb>uP&#B3`hj?_eH`7ZEA7 z&r^)uR!^S;j6XPza|l)3K^UCKvE3xobXPpo*dm&`f99g?eG@yzN((JeeBWZLXPj5g zDR$JImd1(-e)vuuyY5QwGczRH4IoYmKa3+u| z&2lO*FA_es z6`mF6YQh05jut^{mHpWd`E0@1ZTC!~lOJhiU*vjb4=s!q_HFWLaBn(Q|1cWkm#*!1DN;B{*|?FWOwOtq6Bz33D_uG9C(3)oE=kPQl(9UT{+wm9tXr!% zpm6IlZ_6)iJGvfPr@&s}?u48%*K1ZGJD#~GTmF#hpanbYzQg?TfRT$&L;MPFH5E5H zgcb8q`!czqD?Jul*0TBdlo}K#_(bfy4;g}A%q@dhP09`{(Zdpw!T}=Q$O+%LF0H~&`ri=AC$nhbGXcPJ*=^^CtsffD#8UPER z^AN`fxAul}`r10X`J(WY!k@xCy&j?gb>N;4!IS1VR6%w zCp${kZmtiJltQ>dET@BH<~Xz$`x@SGcW=0in=2eZ)1#{dhwWPa$p|#ZAr)fSRCiNy z1u%Y>hS^*CIJkM5hYAah6B`HX-;rdg*>la zu}=mgb^YCMK6op+xNmBC!@MBcyM+4kTHX%UP9)abI^LePmeweIu5>4W0+u=)?=_UC zmYxolR?aXl9bqUG7aTc-i-%8Ojzc13d#vPc9a>`PskrBCPfmKVMy%lM?{2T|?P3M< z#JdNC+~e-4=<+>;Qs$06ij5IAh0mxF$aN)&MVcGL-DK95^H%}b=%}EMM zLvEBUa~ynqdD0VMy5k=xWe~wB8d^Gfa~v`u9g^!%1|^%VhonxJrx4{Yp~RCdatAL? zUkFuhE~n)hBBvb;4u|={rx_TTnE3_Fad7nIbpbde;wWeU{B0R;FSwfv={Cy78bDW1 zPf2!)g_Vs@@2RD;H;kl3$^^FQ?C{JIM;O?>GsVuq$@SEW_WdrNB^+*PZSMl8Uqkhh z_Sd#_wS!sS-%digp9VnLA>2WeJiNLn0zKzua(-ylxDdv;$l7@7BK7 z$m*N0XghYG&Atq`ERP(oJd}eQ09-@n;FE(wIfSJ6r8wke1?9M9p>uo={)^Jgo#Q_-V`vh2DEqc)whSaYJ0ot>C5YBtD*o7VkX7;eo9P2C1-e~ zq-A9J1>}r`@geA_q@cllDZ7XGQCAy>XE2;2Lv<4pI{T)QlO7;92TxBs2cP`Du#|u_ z2UJc-o(Dj=%O@oWEvHme!c7K*K0!OiyV}%tu(n4f6e2lZOD!oWL48OvqXQ@doB+=N z8GyHdS->d}8OR0{0~!K7fZ4!&U=gq!*ach!t^(JAJHTDw0q_KP2cictfw(~mAVrW0 zNE74&iUZYv8bKdHv!F%LDrgII0|tPR!4zO>FfEt`%ns%TOM-R5mf#q0BDfCR1nvP3 zfIopp!IR(_@G^K8e2RdGfQNvO@B~2|K?}hF!4bg;!5M%99Dm4FJ1J5JTxgqy Jd)NW!{|Cm=Wbgn0 literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..4a2f8a85b579862d18102fb0a959cfebd29c20bc GIT binary patch literal 1366 zcmaJ=OKj9e7`A8J64KIm!z+P8^W+(Cy_?M@OVfvUvrZPshVILd7OdH2vTMa_uk0~N zKvku-QiY1BdZ8S8NZT8z5+GH*Ai;qnQk6~BQ^g4hl~$sM9+AS>j_{_X{VyK<|3AOK z$3NwUuGvHsAFu#&FTK2}ZFBo8uWos*V{}YN9C`P>iL^X9omKgeA}gjfKc|}uQiW)y zVXOSkAeYiLY>*t*F|zRsDkW^$^peW&4(?1Ld&VIRhsa3R%cwH1^8FjC`I>2wibFC6 zZ7Ly4LuDP?XM&zg#U0t*lBhT6mG>QNuS4u@sku{9ra>ss48ir3j%*uK8heADY0c2; z4lTKCo?{;P|AsuVG^5%AD~sw>gBDm>)1#2YHFQohEtT&JCi)V*2vfXc7g;e~Y{!^k%>T*ZXoMY6hm$IhwESUOGtO9Mq3p*kEyS^b{@HoxjdVD-{=`s`A$R# zs?_R~n4Mh8A)|^*wx{$;Z(r$xIMfsQXr+Ik;9fbCUZk$N3+CyJ)}DF`>q0D;^Vskm zqT(Gm_#YCO4Qx1;zPjVk;UOV>n!>7?3rY5cpn9gg*nbals7zC)^3Eo7Y4&SQ_$Y#{IY(G_|5LtkCL zSct~M;>qE-D2BywY;*Ii@weaEuvqQ`nADdVwX9PwyXJQVo8i{-1X*?<;>{a#}`vZH6{h9p}w1W^B00+T1$bbfLzy= do91$y!j-v;+$HV`_ch}MVEr$ZVMB0Np8@X@#(w|+ literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift index 8d973a5..ddbdea3 100644 --- a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift +++ b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift @@ -10,8 +10,6 @@ import UIKit import OSGKeyboardShared enum AIAgentShortcutInstaller { - static let bundledResourceName = "OSGExtractTodos" - @MainActor static func openInstallPage(for skill: AIClipboardSkill) { if let shareURL = skill.shortcutICloudURL, @@ -19,17 +17,19 @@ enum AIAgentShortcutInstaller { UIApplication.shared.open(installURL) return } - openBundledShortcut() + openBundledShortcut(for: skill) } @MainActor - private static func openBundledShortcut() { - guard let bundled = Bundle.main.url( - forResource: bundledResourceName, - withExtension: "shortcut" - ) else { return } + 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("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut") + .appendingPathComponent("\(fileName).shortcut") try? FileManager.default.removeItem(at: tmp) do { try FileManager.default.copyItem(at: bundled, to: tmp) diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift index 95d61f2..5d64b06 100644 --- a/OSGKeyboard/Services/AIAgentShortcutRunner.swift +++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift @@ -1,7 +1,7 @@ // AIAgentShortcutRunner.swift // OSGKeyboard · Main App // -// Consumes the keyboard's pending extract-todos payload and opens the +// Consumes the keyboard's pending export-skill payload and opens the // companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add // x-callback URLs so Console can record success / error / cancel. @@ -13,15 +13,22 @@ enum AIAgentShortcutRunner { static func runPendingIfNeeded() { AIAgentShortcutRun.trace("host.runPending begin") guard let payload = AppGroupStore().consumePendingShortcutRun() else { return } - guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID), + let catalog = AppGroupStore().agentUserSkillCatalog + guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog), let name = skill.shortcutName else { AIAgentShortcutRun.trace( "host.runPending skip unknownSkill=\(payload.skillID)" ) return } - AIAgentShortcutRun.traceBody("host.titlesToShortcut", payload.joinedTitles) - guard let url = shortcutsURL(name: name, text: payload.joinedTitles) else { + guard let text = shortcutText(for: skill, payload: payload) else { + AIAgentShortcutRun.trace( + "host.runPending skip textBuildFailed skill=\(payload.skillID)" + ) + return + } + AIAgentShortcutRun.traceBody("host.titlesToShortcut", text) + guard let url = shortcutsURL(name: name, text: text) else { AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)") return } @@ -55,6 +62,21 @@ enum AIAgentShortcutRunner { ) } + /// Navigate: pick 高德 → 百度 → Apple Maps, pass that URL to the Shortcut. + /// Other export skills send the parsed lines unchanged. + private static func shortcutText( + for skill: AIClipboardSkill, + payload: AIAgentShortcutRunPayload + ) -> String? { + if skill.id == AIClipboardSkillCatalog.navigateID { + return AIMapNavigation.shortcutInput( + from: payload.joinedTitles, + canOpen: { UIApplication.shared.canOpenURL($0) } + ) + } + return payload.joinedTitles + } + private static func shortcutsURL(name: String, text: String) -> URL? { #if DEBUG return AIAgentShortcutRun.shortcutsRunURL( 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 index 72884fb..1ee1f59 100644 --- a/OSGKeyboard/Views/AIAgentSkillsView.swift +++ b/OSGKeyboard/Views/AIAgentSkillsView.swift @@ -2,8 +2,9 @@ // OSGKeyboard · Main App // // Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on -// the keyboard after a copy; long-press drag reorders that row. Export -// skills confirm a companion Shortcut before they can occupy a slot. +// 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 @@ -15,13 +16,21 @@ struct AIAgentSkillsView: View { @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 { @@ -36,9 +45,23 @@ struct AIAgentSkillsView: View { } .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( @@ -49,6 +72,16 @@ struct AIAgentSkillsView: View { 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 @@ -61,16 +94,71 @@ struct AIAgentSkillsView: View { } private var clipboardHistoryBanner: some View { - Text("skills.clipboardHistory.banner") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(Spacing.md) - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) + 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 { @@ -88,21 +176,18 @@ struct AIAgentSkillsView: View { .foregroundStyle(palette.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) } else { - LazyVGrid(columns: columns, spacing: Spacing.sm) { - ForEach(store.enabledSkills) { skill in - skillCard(skill, showsEnabledBadge: true) - .onTapGesture { viewingSkill = skill } - .draggable(skill.id) { - skillCard(skill, showsEnabledBadge: true) - .frame(width: 160) - } - .dropDestination(for: String.self) { items, _ in - guard let dragged = items.first else { return false } - store.moveEnabled(id: dragged, onto: skill.id) - return true - } + 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) } - } + ) } } } @@ -111,55 +196,119 @@ struct AIAgentSkillsView: View { CardSection("skills.available.section") { LazyVGrid(columns: columns, spacing: Spacing.sm) { ForEach(store.availableSkills) { skill in - Button { - viewingSkill = skill - } label: { - skillCard(skill, showsEnabledBadge: false) - } - .buttonStyle(.plain) + skillCardInteractive(skill, isEnabled: false) } } } } - private func skillCard(_ skill: AIClipboardSkill, showsEnabledBadge: Bool) -> some View { - VStack(alignment: .leading, spacing: Spacing.sm) { - HStack(spacing: Spacing.sm) { + 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) - if showsEnabledBadge { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(palette.accent) - } } - Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - Text(AppL10n.string(skill.descriptionKey, language: config.uiLanguage)) - .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) + } } - .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading) - .padding(Spacing.md) .background( - showsEnabledBadge ? palette.accentMuted : palette.surface, - in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + isEnabled ? palette.accentMuted : palette.surface, + in: skillCardShape ) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke( - showsEnabledBadge ? palette.accent : palette.divider, - lineWidth: showsEnabledBadge ? 1.5 : 0.5 - ) - ) - .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .contentShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .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) { @@ -183,6 +332,244 @@ struct AIAgentSkillsView: View { 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 { @@ -195,61 +582,80 @@ private struct SkillDetailSheet: View { let onAddOrWarn: (AIClipboardSkill) -> Void let onConfirmInstall: (AIClipboardSkill) -> Void - /// Content stack height; nav chrome is added for the detent. - @State private var contentHeight: CGFloat = 240 - private let navigationChrome: CGFloat = 56 + /// 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 { - CardPageContent( - spacing: Spacing.md, - topPadding: Spacing.md, - bottomPadding: Spacing.lg - ) { - header - explanation - skillActions - } - .background(palette.background) - .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 + 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: .cancellationAction) { + ToolbarItem(placement: .confirmationAction) { Button("common.done", action: onDismiss) } } } - .presentationDetents([.height(contentHeight)]) + .presentationDetents([.height(clampedSheetHeight)]) .presentationDragIndicator(.visible) .presentationContentInteraction(.resizes) - .animation(.easeInOut(duration: 0.2), value: contentHeight) + .animation(.easeInOut(duration: 0.2), value: clampedSheetHeight) } - private var explanation: some View { - Text(AppL10n.string(explanationKey, language: config.uiLanguage)) - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - .frame(maxWidth: .infinity, minHeight: 80, maxHeight: 80, alignment: .topLeading) + private var clampedSheetHeight: CGFloat { + let screen = UIScreen.main.bounds.height + let maximum = screen * 0.92 + return min(max(contentHeight, 280), maximum) } - private var explanationKey: String { + 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 "skills.install.lead" + return AppL10n.string("skills.install.lead", language: config.uiLanguage) } if skill.requiresShortcut, store.layout.isEnabled(skill.id) { - return "skills.action.turnOffHint" + return AppL10n.string("skills.action.turnOffHint", language: config.uiLanguage) } - return skill.descriptionKey + 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 { @@ -260,7 +666,7 @@ private struct SkillDetailSheet: View { .frame(width: 36, height: 36) .background(palette.accentMuted, in: Circle()) VStack(alignment: .leading, spacing: 2) { - Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)) + Text(headerTitle) .font(TypeStyle.title3) .foregroundStyle(palette.textPrimary) if skill.isDefault { @@ -273,6 +679,35 @@ private struct SkillDetailSheet: View { } } + 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) @@ -323,19 +758,8437 @@ private struct SkillDetailSheet: View { prominent: Bool, action: @escaping () -> Void ) -> some View { - let label = Text(titleKey) - .frame(maxWidth: .infinity, minHeight: 38) - if prominent { - Button(action: action) { label } - .buttonStyle(.borderedProminent) - .tint(palette.accent) - } else { - Button(action: action) { label } - .buttonStyle(.bordered) + 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/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 efaeca5..cb7d991 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -435,7 +435,10 @@ "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.clipboardHistory.banner" = "Turn on Clipboard History in Settings to use these skills after you copy text."; +"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"; @@ -452,9 +455,43 @@ "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.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"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 891f7b8..87a21f0 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -434,7 +434,10 @@ "skills.enabled.section" = "使用中(%d/%d)"; "skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。"; "skills.available.section" = "可添加"; -"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。"; +"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" = "关闭"; @@ -451,9 +454,43 @@ "skills.translate.description" = "按键盘目标语言翻译复制的内容。"; "skills.extractTodos.name" = "提取待办"; "skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。"; +"skills.extractEvents.name" = "提取日程"; +"skills.extractEvents.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" = "润色风格"; diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift index 6cbe42f..b0ea8e9 100644 --- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift +++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift @@ -90,7 +90,7 @@ final class AIKeyboardCoordinator { if case .materialUnavailable = resolution { AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)") } - submitResolvedPrompt(resolution) + submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled) } /// Tap an idle hint card: resolve its material, skip the mic, ask the host. @@ -114,7 +114,10 @@ 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. state.pendingClipboardSkillID = nil @@ -130,7 +133,8 @@ final class AIKeyboardCoordinator { } let disposition = flow.submitAIQuestion( text: prompt, - conversationID: conversationID + conversationID: conversationID, + thinkingEnabled: thinkingEnabled ) if case .rejected(let rejection) = disposition { state.pendingClipboardSkillID = nil @@ -236,14 +240,37 @@ final class AIKeyboardCoordinator { private var isPendingExportSkill: Bool { guard let id = state.pendingClipboardSkillID else { return false } - return AIClipboardSkillCatalog.skill(id: id)?.kind == .export + return resolvedSkill(id: id)?.kind == .export } - /// Parse extract-todos. Empty → in-keyboard tip, stay in the host app. - /// Titles → hand off to the host to run the companion Shortcut. + 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 to run the companion Shortcut. private func finishExportSkill(answer: String) { let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text - let items = AITodoExtraction.items(from: answer, sourceClipboard: source) + 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" + 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)" @@ -253,7 +280,6 @@ final class AIKeyboardCoordinator { AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n")) } #endif - let skillID = state.pendingClipboardSkillID state.pendingClipboardSkillID = nil if state.aiSession.isBusy { state.aiSession.cancelCurrentWork() @@ -263,11 +289,11 @@ final class AIKeyboardCoordinator { } guard !items.isEmpty else { AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts") - state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos") + state.skillTipText = ExtL10n.string(emptyTipKey) return } guard let skillID, - AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else { + resolvedSkill(id: skillID)?.shortcutName != nil else { AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")") state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") return 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/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift index 16bb6e9..6faa324 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 @@ -236,40 +245,52 @@ struct AIKeyboardView: View { } private var clipboardSkillRow: some View { - let skills = visibleClipboardSkills - let row = HStack(spacing: Spacing.lg) { - ForEach(skills) { 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))) - } - } - return Group { - if skills.count > 4 { - ScrollView(.horizontal, showsIndicators: false) { - row.padding(.horizontal, Spacing.md) - } - } else { - row } + 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 { @@ -278,11 +299,20 @@ 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( @@ -292,7 +322,15 @@ struct AIKeyboardView: View { } private var visibleClipboardSkills: [AIClipboardSkill] { - AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs) + #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. @@ -474,6 +512,12 @@ struct AIKeyboardView: View { 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 let draft = state.aiSession.draftAnswerText, !draft.isEmpty { return ExtL10n.string("keyboard.ai.generating") } diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 46f10dc..75129b8 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -298,8 +298,18 @@ "keyboard.ai.skill.summarize" = "Summarize"; "keyboard.ai.skill.translate" = "Translate"; "keyboard.ai.skill.extractTodos" = "Tasks"; +"keyboard.ai.skill.extractEvents" = "Events"; +"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.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.runningShortcut" = "Running Shortcut…"; "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 3280535..f40f409 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -298,8 +298,18 @@ "keyboard.ai.skill.summarize" = "总结"; "keyboard.ai.skill.translate" = "翻译"; "keyboard.ai.skill.extractTodos" = "待办"; +"keyboard.ai.skill.extractEvents" = "日程"; +"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.noExportItems" = "没有可发给捷径的内容"; "keyboard.ai.skill.extracting" = "正在提取待办…"; +"keyboard.ai.skill.extractingEvents" = "正在提取日程…"; +"keyboard.ai.skill.extractingAddress" = "正在识别地址…"; "keyboard.ai.skill.runningShortcut" = "正在运行捷径…"; "keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装"; "keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径"; 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/OSGKeyboardShared/Models/AIAgentSkillLayout.swift b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift index 2194185..a242804 100644 --- a/OSGKeyboardShared/Models/AIAgentSkillLayout.swift +++ b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift @@ -44,8 +44,10 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable { } /// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8. - public func sanitized() -> AIAgentSkillLayout { - let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.catalog.map { ($0.id, $0) }) + 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 } 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 044dd28..19c858f 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -71,6 +71,8 @@ public struct AppGroupConfiguration: Sendable, Equatable { 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 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/AIAgentSkillLayoutStore.swift b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift index be6d884..32b9a2b 100644 --- a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift +++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift @@ -12,46 +12,65 @@ 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 defaults: UserDefaults? - private let persist: (AIAgentSkillLayout) -> Void - private let load: () -> AIAgentSkillLayout + 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.defaults = defaults - self.load = { AppGroupStore(defaults: defaults).agentSkillLayout } - self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) } + 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.defaults = nil - self.load = { AppGroupStore().agentSkillLayout } - self.persist = { AppGroupStore().setAgentSkillLayout($0) } + self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog } + self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) } + self.loadLayout = { AppGroupStore().agentSkillLayout } + self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) } } - self.layout = self.load() + self.userCatalog = self.loadUserCatalog() + self.layout = self.loadLayout() } public func reload() { - layout = load() + userCatalog = loadUserCatalog() + layout = loadLayout() + } + + public var mergedCatalog: [AIClipboardSkill] { + AIClipboardSkillCatalog.all(userCatalog: userCatalog) } public var enabledSkills: [AIClipboardSkill] { - AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs) + AIClipboardSkillCatalog.visible( + enabledIDs: layout.enabledIDs, + userCatalog: userCatalog + ) } public var availableSkills: [AIClipboardSkill] { - AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) } + 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() - guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown } + 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 } - commit( + commitLayout( AIAgentSkillLayout( enabledIDs: current.enabledIDs + [id], confirmedShortcutIDs: current.confirmedShortcutIDs @@ -63,8 +82,8 @@ public final class AIAgentSkillLayoutStore: ObservableObject { /// 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() - commit( + let current = layout.sanitized(catalog: mergedCatalog) + commitLayout( AIAgentSkillLayout( enabledIDs: current.enabledIDs.filter { $0 != id }, confirmedShortcutIDs: current.confirmedShortcutIDs @@ -75,27 +94,34 @@ public final class AIAgentSkillLayoutStore: ObservableObject { /// 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), skill.requiresShortcut else { + guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog), + skill.requiresShortcut else { return .unknown } - var current = layout.sanitized() + var current = layout.sanitized(catalog: mergedCatalog) if !current.confirmedShortcutIDs.contains(id) { current.confirmedShortcutIDs.append(id) } - commit(current) + commitLayout(current) return enable(id) } public func moveEnabled(id draggedID: String, onto targetID: String) { - var ids = layout.sanitized().enabledIDs - guard let from = ids.firstIndex(of: draggedID), - let to = ids.firstIndex(of: targetID), - from != to else { return } + 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 ) - commit( + commitLayout( AIAgentSkillLayout( enabledIDs: ids, confirmedShortcutIDs: layout.confirmedShortcutIDs @@ -103,8 +129,47 @@ public final class AIAgentSkillLayoutStore: ObservableObject { ) } - private func commit(_ layout: AIAgentSkillLayout) { - persist(layout) - self.layout = load() + 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 3001289..bbf1764 100644 --- a/OSGKeyboardShared/Services/AIClipboardSkill.swift +++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift @@ -30,8 +30,17 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable { 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 var requiresShortcut: Bool { kind == .export } + public var isUserCreated: Bool { id.hasPrefix("user.") } public init( id: String, @@ -42,7 +51,12 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable { kind: AIClipboardSkillKind, isDefault: Bool, shortcutName: String? = nil, - shortcutICloudURL: URL? = 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 @@ -53,6 +67,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable { 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 } } @@ -63,8 +82,20 @@ public enum AIClipboardSkillCatalog: Sendable { public static let extractTodosID = "extractTodos" public static let extractTodosShortcutName = "OSG · 提取待办" public static let extractTodosShortcutICloudURL = URL( - string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81" + string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44" )! + public static let extractTodosResourceName = "OSGExtractTodos" + + public static let extractEventsID = "extractEvents" + public static let extractEventsShortcutName = "OSG · 提取日程" + public static let extractEventsShortcutICloudURL = URL( + string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf" + )! + public static let extractEventsResourceName = "OSGExtractEvents" + + public static let navigateID = "navigate" + public static let navigateShortcutName = "OSG · 导航" + public static let navigateResourceName = "OSGNavigate" /// Full built-in catalog, in a stable display order for the Skills tab. public static let catalog: [AIClipboardSkill] = [ @@ -104,35 +135,76 @@ public enum AIClipboardSkillCatalog: Sendable { kind: .export, isDefault: false, shortcutName: extractTodosShortcutName, - shortcutICloudURL: extractTodosShortcutICloudURL + shortcutICloudURL: extractTodosShortcutICloudURL, + 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, + shortcutICloudURL: extractEventsShortcutICloudURL, + shortcutResourceName: extractEventsResourceName + ), + 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, + shortcutName: navigateShortcutName, + shortcutResourceName: navigateResourceName ), ] /// Legacy alias: the three default transform skills used to be the whole list. public static let builtIn: [AIClipboardSkill] = catalog - public static func skill(id: String) -> AIClipboardSkill? { - catalog.first { $0.id == id } + 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) -> [AIClipboardSkill] { + 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: catalog.map { ($0.id, $0) }) + 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 ) } @@ -159,7 +231,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 { @@ -188,6 +261,10 @@ public enum AIClipboardSkillCatalog: Sendable { 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 navigateID: + return navigateInstruction(zh: zh) default: return zh ? "请根据剪贴板内容完成用户选择的操作。" @@ -195,6 +272,61 @@ public enum AIClipboardSkillCatalog: Sendable { } } + 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..f246c20 --- /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 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..03a434b --- /dev/null +++ b/OSGKeyboardShared/Services/AIMapNavigation.swift @@ -0,0 +1,126 @@ +// AIMapNavigation.swift +// OSGKeyboard · Shared +// +// Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu → +// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that +// check and passes the resulting URL to the companion Shortcut. + +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/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/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 80b2ab4..9cbc4d7 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -99,7 +99,11 @@ public struct AppGroupStore: @unchecked Sendable { 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) + Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog) + } + + public var agentUserSkillCatalog: AIUserSkillCatalog { + Self.decodeUserSkillCatalog(from: defaults) } // MARK: - Writes @@ -205,7 +209,9 @@ public struct AppGroupStore: @unchecked Sendable { public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) { do { - let data = try JSONEncoder().encode(layout.sanitized()) + 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)") @@ -213,6 +219,20 @@ public struct AppGroupStore: @unchecked Sendable { 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) { @@ -244,18 +264,37 @@ public struct AppGroupStore: @unchecked Sendable { return payload } - private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout { + 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() + 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/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 index 1b46f05..7115402 100644 --- a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift +++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift @@ -101,6 +101,28 @@ final class AIAgentSkillLayoutTests: XCTestCase { ) } + func testReorderMovesEnabledSkillToIndex() { + let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) + store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 2) + XCTAssertEqual( + store.layout.enabledIDs, + [ + AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.translateID, + AIClipboardSkillCatalog.summarizeID, + ] + ) + store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 0) + XCTAssertEqual( + store.layout.enabledIDs, + [ + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.translateID, + ] + ) + } + func testVisibleEmptyEnabledIDsShowsNoChips() { XCTAssertEqual(AIClipboardSkillCatalog.visible(enabledIDs: []).map(\.id), []) } @@ -183,17 +205,52 @@ final class AIAgentSkillLayoutTests: XCTestCase { ) XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com") XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办") + XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos") + } + + func testExtractEventsUsesICloudShareLink() { + let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID) + XCTAssertEqual( + skill?.shortcutICloudURL, + AIClipboardSkillCatalog.extractEventsShortcutICloudURL + ) + XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com") + XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程") + XCTAssertEqual( + skill?.shortcutResourceName, + "OSGExtractEvents" + ) + XCTAssertEqual(skill?.systemImage, "calendar") + XCTAssertFalse(skill?.isDefault ?? true) + XCTAssertEqual( + AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!), + "1f4afcf7ee22400cbf84e319d969aadf" + ) + } + + func testNavigateUsesBundledShortcut() { + let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID) + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSG · 导航") + XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate") + XCTAssertEqual( + skill?.systemImage, + "arrow.triangle.turn.up.right.diamond.fill" + ) + XCTAssertFalse(skill?.isDefault ?? true) + XCTAssertEqual(skill?.kind, .export) + XCTAssertTrue(skill?.requiresShortcut ?? false) } func testICloudShareLinkMapsToShortcutsInstallURL() { - let share = URL(string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81")! + 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, "/520317da7ae74759b64d5fb069c71f81") + XCTAssertEqual(url?.path, "/65bf33ba4206484ba78d582eaf1e9c44") XCTAssertEqual( AIAgentShortcutRun.iCloudShareToken(from: share), - "520317da7ae74759b64d5fb069c71f81" + "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 18a80ed..a27bada 100644 --- a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift +++ b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift @@ -245,4 +245,24 @@ final class AIClipboardSkillTests: XCTestCase { 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("起点|终点")) + } } 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/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..b6141e3 --- /dev/null +++ b/Scripts/generate_extract_events_shortcut.py @@ -0,0 +1,272 @@ +#!/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" + +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": "OSG · 提取日程", + "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_navigate_shortcut.py b/Scripts/generate_navigate_shortcut.py new file mode 100644 index 0000000..55567f5 --- /dev/null +++ b/Scripts/generate_navigate_shortcut.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Build the companion Shortcut for the Navigate skill. + +Writes an unsigned binary plist that `shortcuts sign` can notarize. + +The host already picked 高德 → 百度 → Apple Maps and passes one URL. +This recipe only materializes Shortcut Input as Text and opens it: + + Text (Shortcut Input) + → Open URLs +""" + +from __future__ import annotations + +import plistlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" +UNSIGNED = OUT_DIR / "OSGNavigate.unsigned.shortcut" +SIGNED = OUT_DIR / "OSGNavigate.shortcut" + +OBJECT_REPLACEMENT = "\ufffc" + +TEXT_UUID = "C1D2E3F0-1111-4E00-8A5A-FD7E1B0C396F" +OPEN_UUID = "C1D2E3F1-2222-4F11-9A6B-0E8F2C1D4A70" + + +def token_string(attachment_value: dict) -> dict: + return { + "Value": { + "attachmentsByRange": {"{0, 1}": attachment_value}, + "string": OBJECT_REPLACEMENT, + }, + "WFSerializationType": "WFTextTokenString", + } + + +def shortcut_input_text() -> dict: + return token_string({"Type": "ExtensionInput"}) + + +def action_output_text(output_name: str, output_uuid: str) -> dict: + return token_string( + { + "Aggrandizements": [], + "OutputName": output_name, + "OutputUUID": output_uuid, + "Type": "ActionOutput", + } + ) + + +def workflow() -> dict: + return { + "WFWorkflowClientVersion": "3600.0.4", + "WFWorkflowClientRelease": "26.0", + "WFWorkflowMinimumClientVersion": 900, + "WFWorkflowMinimumClientVersionString": "900", + "WFWorkflowName": "OSG · 导航", + "WFWorkflowHasShortcutInputVariables": True, + "WFWorkflowHasOutputFallback": False, + "WFWorkflowImportQuestions": [], + "WFWorkflowTypes": [], + "WFWorkflowInputContentItemClasses": [ + "WFStringContentItem", + "WFURLContentItem", + "WFRichTextContentItem", + ], + "WFWorkflowOutputContentItemClasses": [], + "WFWorkflowNoInputBehavior": { + "Name": "WFWorkflowNoInputBehaviorAskForInput", + "Parameters": { + "ItemClass": "WFStringContentItem", + }, + }, + "WFWorkflowIcon": { + "WFWorkflowIconGlyphNumber": 59723, + "WFWorkflowIconStartColor": 4278222847, + }, + "WFWorkflowActions": [ + { + "WFWorkflowActionIdentifier": "is.workflow.actions.gettext", + "WFWorkflowActionParameters": { + "UUID": TEXT_UUID, + "CustomOutputName": "快捷指令文本", + "WFTextActionText": shortcut_input_text(), + }, + }, + { + "WFWorkflowActionIdentifier": "is.workflow.actions.openurl", + "WFWorkflowActionParameters": { + "UUID": OPEN_UUID, + "WFURL": action_output_text("快捷指令文本", TEXT_UUID), + "WFShowWhenRun": False, + }, + }, + ], + } + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY)) + print(f"wrote {UNSIGNED}") + print( + "sign with: shortcuts sign --mode anyone " + f"--input {UNSIGNED} --output {SIGNED}" + ) + + +if __name__ == "__main__": + main() diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json index 5486e70..81e1073 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -134,6 +134,10 @@ "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 48c18a4..4671e18 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: "69" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target @@ -88,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/OSGNavigate.shortcut + type: file + buildPhase: resources entitlements: path: OSGKeyboard/OSGKeyboard.entitlements properties: @@ -121,7 +132,6 @@ targets: - path: OSGKeyboard/Resources/Typing/Rime/manifest.json - path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin - path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json - - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut - path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json - path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt - path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt @@ -197,6 +207,10 @@ targets: - skype - zoomus - shortcuts + - iosamap + - amapuri + - baidumap + - maps - things - todoist - evernote From 320dc777c9218c783433bf2078acdaf64c91ce31 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:22:41 +0800 Subject: [PATCH 3/3] feat(keyboard): add Notes skill and system-style typing Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior. --- AGENTS.md | 2 +- CHANGELOG.md | 14 +- ...待办.shortcut => OSG · 存入备忘录.shortcut} | Bin 22481 -> 22553 bytes .../Shortcuts/OSGExtractEvents.shortcut | Bin 23409 -> 23414 bytes .../OSGExtractEvents.unsigned.shortcut | Bin 3288 -> 3286 bytes .../Shortcuts/OSGExtractTodos.shortcut | Bin 22481 -> 22480 bytes .../OSGExtractTodos.unsigned.shortcut | Bin 1948 -> 1945 bytes .../Shortcuts/OSGNavigate.unsigned.shortcut | Bin 1366 -> 0 bytes ...igate.shortcut => OSGSaveToNotes.shortcut} | Bin 22013 -> 22552 bytes .../OSGSaveToNotes.unsigned.shortcut | Bin 0 -> 2114 bytes .../Services/AIAgentShortcutRunner.swift | 44 ++-- .../Views/Components/MinimalTabBar.swift | 48 ++-- .../Views/Components/TabBarVisibility.swift | 6 +- OSGKeyboard/Views/MainSplitView.swift | 4 +- OSGKeyboard/en.lproj/Localizable.strings | 2 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 2 + OSGKeyboardExt/KeyboardViewController.swift | 26 ++- .../Services/AIKeyboardCoordinator.swift | 61 +++-- OSGKeyboardExt/Typing/TypingKeyTouchPad.swift | 149 ++++-------- OSGKeyboardExt/Typing/TypingRootView.swift | 54 ++--- OSGKeyboardExt/Views/AIKeyboardView.swift | 3 + OSGKeyboardExt/Views/KeyboardRootView.swift | 2 +- OSGKeyboardExt/en.lproj/Keyboard.strings | 12 + OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 12 + OSGKeyboardExtTests/EnglishTypingTests.swift | 77 +++++++ OSGKeyboardExtTests/KeyboardStateTests.swift | 12 + .../TypingTouchTrackerTests.swift | 150 ++++++++++++ OSGKeyboardMac/MacDictationViewModel.swift | 7 +- OSGKeyboardMac/MacRootView.swift | 2 +- .../Services/AIAgentShortcutRun.swift | 15 ++ .../Services/AIClipboardSkill.swift | 61 +++-- .../Services/AIGenericSkillExport.swift | 2 +- .../Services/AIMapNavigation.swift | 3 +- OSGKeyboardShared/Services/AINoteExport.swift | 170 ++++++++++++++ .../Services/KeyboardState.swift | 30 ++- OSGKeyboardShared/Typing/PeriodShortcut.swift | 28 +++ .../Typing/TypingSessionController.swift | 47 +++- .../Typing/TypingTouchTracker.swift | 181 +++++++++++++++ .../AIAgentSkillLayoutTests.swift | 78 +++++-- .../AIHintKeywordExtractorTests.swift | 11 + OSGKeyboardTests/AINoteExportTests.swift | 150 ++++++++++++ Scripts/generate_extract_events_shortcut.py | 3 +- Scripts/generate_extract_todos_shortcut.py | 3 +- Scripts/generate_navigate_shortcut.py | 113 --------- Scripts/generate_save_to_notes_shortcut.py | 217 ++++++++++++++++++ project.yml | 4 +- 46 files changed, 1429 insertions(+), 376 deletions(-) rename OSGKeyboard/Resources/Shortcuts/{OSG · 提取待办.shortcut => OSG · 存入备忘录.shortcut} (52%) delete mode 100644 OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut rename OSGKeyboard/Resources/Shortcuts/{OSGNavigate.shortcut => OSGSaveToNotes.shortcut} (52%) create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut create mode 100644 OSGKeyboardExtTests/TypingTouchTrackerTests.swift create mode 100644 OSGKeyboardShared/Services/AINoteExport.swift create mode 100644 OSGKeyboardShared/Typing/PeriodShortcut.swift create mode 100644 OSGKeyboardShared/Typing/TypingTouchTracker.swift create mode 100644 OSGKeyboardTests/AINoteExportTests.swift delete mode 100644 Scripts/generate_navigate_shortcut.py create mode 100644 Scripts/generate_save_to_notes_shortcut.py diff --git a/AGENTS.md b/AGENTS.md index 45a767c..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 69)**. 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 902cb69..2253082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,19 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Overlapping key presses**: the typing grid tracks multiple fingers, so the next key can go down before the previous lifts. Pending letters commit in press order (not release order); Shift can be held with one finger while another types. / **叠指连打**:打字网格跟踪多指,上一键未松开也可按下下一键。未提交的字母按按下顺序出字(而非抬手顺序);一只手指按住 Shift 时另一只可打字。 +- **Period shortcut**: in English, a second Space shortly after a Space that follows a word becomes `. ` and arms sentence Shift, matching the system "." Shortcut. / **句号快捷**:英文下,在单词后的空格上短时间内再按一次空格会变成 `. ` 并点亮句首 Shift,对齐系统「句号快捷」。 +- **Return key labels**: Go / Search / Send / Done / Next / Join and the other `UIReturnKeyType` values show their system captions on the green action key instead of collapsing to Send or a return arrow. / **回车键文案**:前往 / 搜索 / 发送 / 完成 / 下一项 / 加入等 `UIReturnKeyType` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。 - **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。 -- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。 -- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。 -- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。 +- **Extract tasks skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractTodos` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开 App 内置的配套捷径 `OSGExtractTodos` 系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。 +- **Extract events skill**: the Skills tab opens the bundled companion Shortcut `OSGExtractEvents` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开 App 内置的配套捷径 `OSGExtractEvents` 系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。 +- **Navigate skill**: copying text and tapping Navigate asks the LLM for one address (or origin → destination) and the host app opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No companion Shortcut. No address stays in the current app with a keyboard tip. / **导航技能**:复制文字并点「导航」会抽取一条地址(或起点→终点),由 App 直接打开驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。不需要配套捷径。没有地址则留在当前 App,键盘上给出提示。 +- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。 - **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。 - **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。 ### Fixed +- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。 - **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。 - **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。 - **Skill drag preview corners**: the lift preview clips to the card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。 ### Changed +- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos` 和 `OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。 +- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。 +- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。 - **Skill and style cards**: every card uses a top-right edit pencil and a bottom-right selected check; skill Done sits on the right like style details; tapping a skill card toggles the keyboard slot (unconfirmed Shortcut skills still open the install sheet). / **技能与风格卡片**:右上为编辑铅笔、右下为选中勾选;技能详情「完成」改到右侧;点技能卡片切换是否上键盘(未确认捷径的仍打开安装页)。 - **Clipboard skill grid**: idle skill chips stay as one centered block, wrapping to two rows of up to four instead of scrolling sideways after four. / **剪贴板技能网格**:空闲技能按钮作为一整块居中,超过四个改为两行(每行最多四个),不再横向滚动。 - **Skill sheet buttons**: Add, Turn off, Add Shortcut, I’ve added it, and Reinstall are solid capsules — primary actions use the brand green (`#3AA05A`), secondary actions use an elevated fill. / **技能弹窗按钮**:添加、关闭、添加捷径、我已添加、重新安装改为实心胶囊;主操作为品牌绿(`#3AA05A`),次要为抬升底。 diff --git a/OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut b/OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut similarity index 52% rename from OSGKeyboard/Resources/Shortcuts/OSG · 提取待办.shortcut rename to OSGKeyboard/Resources/Shortcuts/OSG · 存入备忘录.shortcut index 0286763cfa763ed6fba34f80edb8e6196f402dd8..c6051787fb19d50159a9649a27e2337a6ffa4bf2 100644 GIT binary patch delta 10166 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 P1^@s600000Vs>~WwxE9G diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut index e1f601f9c1c817461d6950f8506814102d91db0b..148344460792f4f2888862680d7e77129100ba62 100644 GIT binary patch delta 10428 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 delta 10382 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 delta 8902 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 MHgj?%yFBA80Jj_{_X{VyK<|3AOK z$3NwUuGvHsAFu#&FTK2}ZFBo8uWos*V{}YN9C`P>iL^X9omKgeA}gjfKc|}uQiW)y zVXOSkAeYiLY>*t*F|zRsDkW^$^peW&4(?1Ld&VIRhsa3R%cwH1^8FjC`I>2wibFC6 zZ7Ly4LuDP?XM&zg#U0t*lBhT6mG>QNuS4u@sku{9ra>ss48ir3j%*uK8heADY0c2; z4lTKCo?{;P|AsuVG^5%AD~sw>gBDm>)1#2YHFQohEtT&JCi)V*2vfXc7g;e~Y{!^k%>T*ZXoMY6hm$IhwESUOGtO9Mq3p*kEyS^b{@HoxjdVD-{=`s`A$R# zs?_R~n4Mh8A)|^*wx{$;Z(r$xIMfsQXr+Ik;9fbCUZk$N3+CyJ)}DF`>q0D;^Vskm zqT(Gm_#YCO4Qx1;zPjVk;UOV>n!>7?3rY5cpn9gg*nbals7zC)^3Eo7Y4&SQ_$Y#{IY(G_|5LtkCL zSct~M;>qE-D2BywY;*Ii@weaEuvqQ`nADdVwX9PwyXJQVo8i{-1X*?<;>{a#}`vZH6{h9p}w1W^B00+T1$bbfLzy= do91$y!j-v;+$HV`_ch}MVEr$ZVMB0Np8@X@#(w|+ diff --git a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut similarity index 52% rename from OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut rename to OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut index 75d2304d0476e02b09fa039211bfead360f62a71..97ec20c8e4034a2ed9610306adbeec7805751288 100644 GIT binary patch delta 10212 zcmV0gy-tB<>IZ9;ur!kx+?5G%z_dH8nI^7Y#BuFgY+eF*q_bG&ou? zO))7jCk6vm1Op5pVq`HmG%_+aW;HN3G%{g1Ic73pVq!NiGB7YRH8?V1lduCLlRyC) zKs1)cUf0{cJ-Mkycd}cJZ7X4$C7vXzFTys$M%ZZ9uWS{?gs8mw6j?^gy!4rUnaqA0 zq%ZB~7UYovGH_`*6SJBD9sv}hxD~R|2DCXMr$67v+?=b@&hwa)%mOq*BkL;NAJ@hR zTE}v(lo0E~J-za`%&M)AW^!V5RB=�w7}9yeS@GM{`mngz}S$#w@l#I;~Pi#?+FO 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%0AJp>xo~upM zV&>1Bs;aupR@YbSQLOf_O1=~SSFEry&Aep7Hm#i1@KMakO@ZN+-y(AOinQmq=TM*T zfQa&%W}q&Zf#pYm)RX7+M3aqD-N84|cyatyj$`l=1q%i|?>0?KEXahg0@KU2fqyC& zMJAo{hpe_Q<6NUh<hUS}WtitHGUE|6)9K0M-OSr&3F8 zxaYKdNtH*}*g0_(K4OVdN6M}}?E2G2s$dH_{j8i!L=UxFe?n8L2ekI)g7iFO*31_M zkIW&X(95?x3fPs~f^;>EqDU7b6bgyAc4IiK^NO?`)EvIsaB&LFN}A+z&VSnND4ey$ zC4og&6jl1ejp?d3LQubGY3WVeSs%c0xVzHA52Dvd4$u^#T4Ii?bvAw!)WB9;#AeMl z%n6phJ8H*cK^njs9tLQ${4|o}Bb3;d68+7NpePKIoQ1I^Pi2>h1gvCSg^R!5BI3N4 znK>F!8335Le=VrW7dYwU1%IR;3gOD*F5Y6PNyo7t$8D{<8@mx(ZeC%?tFKH?!i#la zXBNS6Bsg-prL&x*DmzF8{0X*&kn)+pW4M#6f%gCZf_gqIgL>7*Xboz$FkfRk3S{b$ zau$6lJrlgI))3BrZq2P&xP8^L08)%=gf(Wo4@B2upm^NOvDu?YX@AEmZJ&m0_RnZD zoQima#AV4k7=9Xg+S;Johs5ryH&T_=AuWxwY>cbMygLgSmGDx(gTrMg2i!voUYVMq zvYq{*{9e}hcA@48AsN0}NMhCLmdd(W1gKs`6M%Tx^0EgcE7CRGb-US_BMA4k2xa6W zCI}IgS6gMY;(AxM9DjM@w&*aMJNHIl7Vg|%!=m&)Tf1NWh~Al$c|Xv0wz5=wkBYW6 z>6IeI`BLGrWq1I&rON;=E)%Fh+L2jBHsmo#_eS{V^d&W*e7IXs6g?T*0jDLT7Rnnz_IqxyDy2i1CiSwOYzo*{F)g~GZ7jjuRg6%RI%s~X zV;3o&<=}G%R~dAnzeI3wLYIh|JXT^+cE6{~hmWveF6{vHQqe6+!PPAzDO(zo$Xj@LHPA{^D6k${t^VCPvu<_1Z{@F-$Yam6EU<4w zpOyqL-kT|XpQn;&bAKlksI&^rz4(21Sm%}se}GCP>k$<+Qy+|I8A&{P0R&pOv|$+X z`JXm}BY(d>Y2(arP5^P!^WW~9>cWgb`;>tiK2${z_&EI+vb1CHP#nnHw+d4@;ce7O zD0au26krNP-|=U<7nn({#g=m#e7uqRS6jm43NFPE3>e7ZLRo-xqlgHGS)*q|FXA+W1LA^ZBE=b8-AQ> zuAoy!=EITp=Ms}DcqpbM4YNau7Z=(YUoQ0+7kMQR9{Su&;&@2X-ZCLleVdLR9+loS zv%%{qk6W@B!;k3+b8vy8BwZg70PiEwakQT1$0#$UsWrsG)C6cvK-FYc;K*CUB(V35 z6@Q-LfL;!jS7#Ci?Yq|YF)V8`9Uk8!`g!;ys6z#K)2k@$QGI6x`5O;yL$SUp$RxyE z5XCEqL}Ya=6I~n_PVx8i z0PxI?OqQwLPv`N=ZJzO}wnd<+;apIxF@+wzJ}t&LQuc4XK=4iKBR1S95}m7;p&LSz zA4A?FnnzplqYF+?(madn@e!bjzISEBF4d1m4kR32Q%Fwvr4NSdqHuaNdHz5oXMeK7 zJ{{^@JA-30ip@G8xwneNyg$YHbqJjidA}=lWMHP58F|y zF^(QWWdREGKR(V4Rt@@qB@_%wjDIS!h8ln$ws8*VaEW$l+z?T23MDW{q5N0SOD$xLoDY~$v$pS? zwv5I8UOI#`A1JC^KsiS9ZV4CTSjthdRqKTw+c|yn+J6zrZB%pbvV4|TzJK-wo*o`l zdHuuI9#k}XVb5J84HUeYiGYVZ11#RJ&((Vp|84DP=K@XLvpQ)35qkqL+)U77L(xTe zz-caF`CD&`Wd0n#8?K#G7>bk-=z_;f=!BkbzOMAAHb@Rownl9KH|sgxLKTk#(Rq&x zn5NH|?~aHoS?aHsIG)3SZhvv0X)aE@Zd3)HLN%m*4BE$-0qcP#-9CuLizB~`uKV{S zxWyZiehZ_Z5$a{Y?sL2Fp9~eDL=G!f^*$pSF(R0Fdy;Ea@;`z_qbSY9F#;awm3rl< zSgtE;Qc$v_w=B!l-cb(^lVv^duUCdTm~P%|9{geJR@3zMr%sGmm~0;kEobcd#n{{m zbuPtyV0MvaPuG5vHPsHm6^fuPK2B5oI$SjwGB);y(YCnJOV3=)Gz_W{UMv8|Z?1$T ztYWr&3DP&)K)XIGap*~3eZ~O$Vbl4l7O2T(HB0;AR@STuVs?0Ld%@jARd{UE@9vWE@x$QbY*yS(;OWhA1p0j5D-^JQ~(edJ_rf`e-q|Ib#ruY zZ5e++Pfp+mR8>_;MA9H3A}du^VQh6}Ul0>lMpI<~|Kx0GdSMVb0Y>N2BP1mzR&yd? z=n`RcbYWv?Z2+v`33EbuQekdqWnusP(I+TYdoU~@F@4b~DpUwI!Mk2XcnV4pRT57K zhvZamYh@y>?Pg!=V|QZ*e@^6Ohi5DT;p##%Lo-A)MKCQf00BS=E#Lz%EjU3nK`ll^ zH$^c*FhesrHbwzGg61zUIRp9$0E0{}bL@20F&!QTUTqfIGMl!GA%Q}o-H&+f8YW+K{i4yFhw{< zGD9&$G(k5oRv2IbPKh!(EABecCp*+BJUu=?EGp7>Op5`a$#;{X?kT5 zQCY8C7$ZRJpn|JHLpksLLl$Y|W^!+B3_R}DL`5AQMn_9bO-@%=MiFw$I9pdne`5fsn4m~XL@+ZnT?k|3AVG8>5OoN@Sea1eQB+k{P5^UmQt|0ipb!FL zYhz`HI9tF0G%YpY2Q4%)GBqtjHbyrsGBiUoLODc1MKLrre+xBA-B`d$Sz=>JWLn?@ zTU=!S`~V673jh4lT_;{(R(mrFf*51uRaRf17z*o5O<|yASqER(UsXD3^uPf%EjHi> zEi^JSHZ4RqK{zclHAFN+K}AAFGBq{=Y2XfJW?v8{V{dIPVQ_G4WiD-RVrgt;Zf|sD zb1qY8VG6=Bf8^}eXJ{QBX=-b1adLA5qLjE3Nz2tiaB%no0_Q_=Wnpw>P5?v^q{Mq& zS4Qb~S7>E!Qgv=yI1KRtkvMw$0Q^#5ZRBpzCvW!UVq1*n0>uK%0?z`}0^YLIps{xZOlI;v$^qEfsCeP3FW%mOq*CBt8|6b>u+ zl^Wa~AaHG|S)MS-xsJ?!MJP5sK6|`s0w7t6(wYwa@%E5phOI^g|BjTY`#ngFc=i&V zVFemQAM>+9184@Q{Aa}Ty1~mes`BWwm{1+ZKOOQeZh=`NxG~LfL*LbuMuTK|b zzDJdXg`QQSaGpW0Spfefkw?j*L3{YBe~SeG000000R2^ykqIV9`EIV^-(SSkO9D>3 z&^!$|KTBRkDkLOoG|nv5POcjEiv<7x1_S^AVVJK<7ipV(c)!*Ozba5Oc>+1D9!3A~ z+u=%^76D^%vjGa$Cw~9{RkA%Rxh-o_64EBM`v|>6^d31 zoM-94u4wOY^9lywPp(V*X0T>(CJ_O)sw+8=o!zTIg%v9Xi5mF2Fcz<6mcv`uiNLI; zOfR}-xi{`&ZBY$k@Yxf#WTB*JKaeP}8+l;W5h=xw{-o;Q7k@9Ql%xp?uaugZ-er(t zx_d6^0APNo9K0~E-ewAfmSbi9|0s;LJ}tqi@l|wHDPl!$3-uSacFBsToI-dr%fXz} z^OPKYn=N&hZ%W>RtERUxgM&gj3@E5EP!RVyj+3onB#OK4f(n0ZI$CkjCttk%FQW=D zQT>k=)`f%!Vt*>v^Ger0TwH?|xmT}s`Y?PdT4FLj1UDqc1WW@9&)Qs?p>|WvidcG^ zpVdQ8@V(am3#_iZ6ZJ*w#i#d17=IcF0KfaAVRzj7`r1tVgPgJc z4QK@{Qe1QM14czxxRhaND5Gmn-`;6g=#<$ZE+G>&&aBo|6yyC&t3x>u(VXTZw2sT{ zfQWxNfnWj8bm1ARVY#<;KVl|~Dz8IL&fUo~;S--n_Lh8)X*rDrmky%*;KqK){#jlr zOv?3YV1LWriDvP9f~oBSR6gncnZ)UgIn!_7sQWuw-g})v;)_uH=x=8cBm+G0uKTfR zq~$^UFTADRd4>~-q3k?1$*I@0zgc7=Y_m=el>BhSGgnA?5K|6NVZL&^Ma}>V73&tU z%YIDcl}on1j(940vV5+QKvcDDB0RH8$ZxRn^M7IeMuj24h7;Ol3-SQ!cXTljv0}t6 za@!vi<&B9`_fCTKe5~!AaG4r!JzV@;OMZA(CEu7@qHu}AQwtM}5;q5lXK*>SJmnZ3 zDLqKD9dl>f{1Et$WmwQb7h@>-2D`8Az(kXgh1GCOUkD-}m=XBMKs3urXdnu+Y;Fgf z+kbImZ8s?*|D%6Y-x;nG&(Uhu=;@mEQ#QUe30Hg)X4?g~i?GXs?#b;ZjBMM$8t@Gz zN&O0#LbfqTZHDW~Y|uAQS*4%-W%j9%c_dDVoy0R8*H9ojaZ)Ujy9~o=Pf&z>WV`iI zeWd<`ZxHFq^enNCp2}1p43iDCs_uL)gnuT%oHPJ<8Q|EN2P!&!wnAdlWz9=3&A@Y= zyRQSw)d#DXPaw4Cg_cb}*<6N`=VDUbr{iUdl14C!wg!GYC_`yXyuZYlKN2jnAt481 z@sgXKg1qyOL&G+U2u$_%@jX@k7 zP0#jAoX)d_hrTg0*td-VnaY7Q%kUr zF{A$*l<(|@owc02C4ib4=8BI)lYiyAHnOmPXhC|0LS`)fyV8HQXccwMf&MMD+sdWK zNHdf}$IsK2_}E&|4N}D`&}f*E5xIFOn{^8biVdKLO@4AzVs z1mbsF&Ijd!WL}tt58DqSs>q4KpF|+^D{V}^P~lXz5akFnKF4lmG^!108GqV&$RUBL zgOhl&w8F!z**m}j;x=4_{1^VkgeU_u7z>{7r&2u?m((K&=Y8DbE z`x>CNvIUm^iPm%^X#+ZX?)O{K4$K=E75uRhc_!O2wU>~P0FRc0E$emLEP@dmMl~84OuBhb;+9N3ozNxT!O?|tS-2AGJ^|JA>=V2*naU5ujfpDhl5~o zal{ET@nvO8FiH4}N`HbQe|Uq2sGnf@ky*RkQo+ORllPJSMVy@~;3)gT3co(Lqz;A= zEhhbQt7$ro{u67*(@78mJ6YIZZQk_RQogRqYCKwBLAZx*f7epv8B0W1)=eL)u2$!6Ih@ z9Qco|s*_Bdyf9ZPK~cv#a3uhrirLN~Xh%YoH*Z`?8)+8&a-~(#FQ&{jKH?zw1x8x9N>NOIRUe^TeUL zsO1^5dd-do;jjH7g++^oV@^F|1p7bcuVJ>4AF3cDgMS>ohFcfGz5avDa4=;btL9ZVXQxWdzO4_HrJ*%0++B6k@#SxAJuI44`ZhHl ziqo*rvh~+=M(GTd%k3YirsY7RsxK~P8dODw41v@^!w915-u1PHCn z3X;uqAAjrEg*Kp*x4uW%6?AJTO`azSPmC2+F6*G=l$wq9bRJWBVNW_uIYySK6ib$738w> zkY#pKSJEr;KslTr3a47JvSfl@N^i++z3n~6z1k5JF9Ch79^OLV1aV|Yc~Q1Gi(L2W z9K?Pa06XP&VWvwD-sP=Kcj^mE6rHv3*9&u{Y796Y4Wz8o%7t8+s%*n|K_Jv>?6}A` zWq-;}Z$f}Xtd5*Ne}!EkUfETdOf=01bkSqmz z!h%Kgs#yhU3gcN*QNwqz$;0ceT_HRXlYa(PM21Y}r~EM0!jJSXAAo<#vTY+u?Vz;uf1WNlNM9iNAN z12#a)Iy!AKk?M7HG$*zce1mg}iZ3q94~ow1k%CJ4FhCQ`vu{*`nxAuH{p56ABUkXdrqhUf|6>KS;Pfo~9<2|9N*u-F$@L(2=4O zlofAR4D$%ywGSY1#|8Tho+!yE^nVwcyhG%eQg7I>{H_gT(x@<6+vezzYbvRPPrK4II^ zD~Zse18|Tw$1@D~ubmTa%7g-By7y8=u=91%Epf7jWOi%C%}E}dl<&T~7k|by8g|ZQ zStmj;l`mv)T$m}OU60OqH65B0PqR?e?%e)v3yq_%*@bhOyBMh!tl zL-KSRzmY8aGF5#7!ZIK3YPIPu-=)!s_DRh(Bf2$P2ikT64^xs)P^YOzneQUYpkFu_ zsB9JwD_)HTFz_ICEUl+pa({0(UTfu_%53B0ZPc4bdkIk)3TM=3r=m-g0if2DHY(!Sl&}%sGwT6i;BgEWM@O8#TP4hI`(qqtvC^UgT_qT|$bP zjlxZ$mwZT{IbDmptF=ObYY9;C3`1@SZD728z65QRmPS1eouu|l4S&0croC3&~4Iu==)0+#=P2j%*owK3ivZ2F z;9_7(4~&Ff8dCuBw!`+TSh7Be1KO2UY}#n|bx7mkSPS^Y_)&F3>SDy+`1%B8X#!Q5 zgNYqdCsWCnQC_u?9Uw@kg|Y@vEbmYZS{qd}>5o!@{Qj+coE)i@g26%p0=4<_eNDIL6|2Q;-1VYr&NU6^pXHerw%?$+!0xzZitEh<`HiA}u+W zWFrRBlJp>f9cMEnWh*bl#%m z$6PkLM1OD_>t$fR8eL)9ZcmDKW?cwnbfJk)QI;`y=s#-^3=+&stw`}tY;;muJ)~Fx zCho0@P4p6Z3O4eYjh5ziOG}y=Na{>=no5W><5E09& zmmE6#K-iP&0yjlAsZ17}v3&yg98q*sda+MpyMIHKi_R2xiGx=82+iYl`pTV9^C3CX zdA##S#WF|(@b`;tq6s~_X?vqn_SY=E%YTcAp_WJk8MR|xqm#<=LpOp3@yB?AW!!F_ zARpVvY{T#Gr(4#MAR|8&vmHTD9_e(&Dyquf4%xO3qs)E~w}(h>MKi`%QpU!R=gAeP zy?-`ur%>rYE2!vqz<+dIS7JekDR<5 z^P2G*R(hTxA9X5}S(3DUNuXe-hO?kJ+E<8-2Tqvo6Dp>2S4lhFUYL7O@c@VDH=nuy zm=TbBslm79*Axi8a4~5R@)Lox%V*auEv#yC6%BOc?_}DnCxU!bD&c(Meff%sf`1~( zr;pob4Ap0wzSo9wRZG^Pag_{_K#LZ`i6|_nY}%0RMVMXC4}e7HN2ZFQUS7@Y{47O1 zggAIi!)G;_*3m)%?9S-SX6pFqMU&#aQ7>~diUV6Bm+U!z?T4@(`Owu&s99Ci6NPG= zE#{eczakT88}pf>0b81}Z*}}lwtr~*{#rO;%_KF3R7LLZ{``3rpXlMy)s*@*D5rr> z3lUu$*8w*TXz#im-!UE=iQ+}L-271+8XH)iTa7&p>@w^#W4gYBoT9ibi`v zP_0}OQa8ZinRH=uvzrK~tL6fjHDIjJlE2%|dAAtd8qD(e^)&WoFn!RRw$=ZwW1v@> zuAfq-(6A&sy|OsmE+5Srz<azJ5jExI>k_9~@gg%Nbu_#I*6wlP)Fb ztk7Fp$pv2O)q9BE_7Xrm^)tfv!z~~WD$unN`lW#J1{k!X=L8xX?~Vtk|RG3M^gpt@=9IS zbQMJ?E?jH~IU#}%bAJTgj+M@pI{ifltd?I^{>l3iHprjAOM zD;NXdm$q#7zYY7WoZuje zmfKK*vAl2VHJIl1+Gz>PueDKxvovkY?OKmR7!6o2ytKzAx=zhbsoiZ`9#FBCvp zb3>-}_}lM%`vc`v)Tn>c9uwe+@`HEg>lN235gx-Awhu7RpVVVKb68DWi}Hjth71;g zeiK^m2#yB@-{Ar-$~jF#mS#N-!n0pg)kUF3w`Spt2wIZE>-1o6Z=Z?>wkc9o^R?2T z#Gh#FRDZ-9)#V}w#XT;fsbJBpmiKVimBu}d4{&!W6KEEx^=(*`=8qkE+Fdois|H_&yxhEOvBkmPU)rhv#qcKJ!wl5SobJUqA_l%L+ z&&0FB@n)3$yNCo{-Jcy4VsCTVd0-1K18X|n^b74*UCNCma0*lPk&rg zz?#hhV&l+SQemR%`f>gkZTikJHH7VDP2qYNQyyAP91)!(Bm1rM<*W&OAwDmJg3WZ* z6}~-E@xjT}drIAR!|IR+g za^ByydYP%S*1x)U7+Bsy@if!+j#oMz+ykMnRdljyL;On1AR1+omN>HETY* zV8&3xSF@ZT?`Lsx$MXw$xSwS5laqN!gKx6C&P$;y#C|+!)lnaO6$YBE141i^$xjvu_ z0v_=jPny83U2#npYMcywrN$^DUoFba@{=U_28ecsb{pFaep3`FU1rrWSOr0~o1?6X zp4j(K+zOg3IDbiqk*-ogH>SM3udDEqS|;S1cMddb1r`{~o^Ma!b%b3Fh;$%tC>e0{ zDE|sdhc92HGNt4<9h_G&2joZ~2Y`+YT`A^c8`EAS!tc%&GJ?+UCs{kO+b5JPMe6xj5pChKn)>eM$( zGv|qcY@5Yb5^`>y)eSH01|XCWW0G=@01cBLGKVxdf9wJr*m`c$0|g5{zVOQJ?s$>n zi-V2`NPh+O47&3Y4ZIHe(o{d*GiH6B(vAxXx|6*!iZ7IIl$a%<>PI`!G!i6RHFkzi z4;#h2zqq55ip974m2D$CXns$XQP@Lt)z;r^$b0Jw_4S3;#FEvF@r%Bo^_nkq6-{@j0`WH;i@cKz3p-)>13?po@ z6_-*cH2#qywd?q(xEJZXwhsX>ziS_L$$&*Ln_u{q!A)q_FHFv$tr%CT{>=Fgsn-Td z;D2vw6@hkYhj8`1d2hGRd0p4wdFZlHO->(vIe5@OrC&~9K6ZYK0`Td}A>9Q9MWM83 z40c`)((&Dw)$M=%XSlAD@(@Jv$jK$-ld5Rg{REd#u!fjzw@FJrpTD#8>HHrF)(-9n z&||_X$kv!{_neEMTe0F$Jsfy*|IW|`pE$wL-VNb{gMhB#Cbp1BlHiAB<%c~=MC-7o)$^B*Bs6Kr_=QS zTfu71EjXn8ASG)X4{tz{RNiq-D}%DX?wJbd;XUyVl7O5@y|iXkTbiRTJ@CJhl7A`( z1HO$Kw(B3u0-m8GQbI`E-Uzu5YPvuUYMi}_Kd=| znaH)b?kHO04^&c{z|j+!S>?eH`hPPf7P34S4R|7OdtoXj;P*!`3`o&VF%Whl%0c`onKbpjHwCK6$^BB&uKzW55d?v-v=30Md(ozgbbk$R#Q9&o zfXF`!laM@Ox-X$)@HrNc*jusT6A7Q=|KC6Fe!YMS*zHmyJm9n&CDii^-Vc@ zDQaMS;HAZvFc}7Z54Qg&Jb%%`B1Ed0a+k^2%$>yKGEXt&Yp3j5%Ik62_nD6pMk#6B zP1(NYx{yH-2A2_pT_kQS$-AIlqjU&2IWCIA=1}I^}sFr+wIGW5n;9qkGHt$H_HExol8K(n-d*R9P$$uEHgM82y*YA(fw7Qc9;!0_$H6X|H*Ra@`0Xj=qij;`O~S> z^CB=|UnsnZxDkQ15oZ@h;=r$@3KS>GPnGlt2tB{_MDEH@Vs?0Liv<7x_5+gvQXdQs z&wXkD00HDMI*tO9EK(x^DFu^JQX2>t9v??aOH56ZbW%cpBKGP}b#!obbVF}$bO0g1 zR7rGY=wWknWpiH;88{{6PH#zW04D1~WoTh`X>W305F0og$V5nCbMos(VQg$-VPk7w z5D_>Yz;Z!jbO26nA@UGRICA=K5Aw)_RC#dw!F5S(aO6>SWpf}>=oME+QFUo!YZznZ zQ+0A?VPgmeG;SmE0GyLOQy+h3@kwxFfS_S=Na<{78yWWC7e{P)aA;0-ZDM6|68`{y z{}Jg+R5Uj*IiS)Q85$c@PGN0jT2Ntf00%*AbUE7dIYjm5L33+HZ*mY5lMj7%sBk}AAq98ZYAtECrUl1BNQyBVyB}ruEX=Z6Op5`a$#;{X?kT4@L5h=7$a!1pnvkf0WmEyz_%?lM(R01HbN~hML0$>03GNwK{qg? z9Y{$Lj{tx=3h@&72-2>t<8 diff --git a/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.unsigned.shortcut new file mode 100644 index 0000000000000000000000000000000000000000..f25cddf1c71b5eb1884c7598c92d2d1cfc96207a GIT binary patch literal 2114 zcmaJ?TWl0%6rP>ia#5H9g%$)_C|8+v_S!CZVfNN8&@FVgyFg3F+3nxmv9mkN&Mc*+ zSVc@VBtB?NswQSN#;6Y(5+xz=0pkk~8q;!7iI)e7FC>O&^a)eXKT|u4R{xiO_CNoB z&iTIYoU_@os_2Fw{NNxdnqi$wmM&ZVz=JDR7E8lgxq51B{LI9;=OnHn6U%6okzrLE z6?2B7m2`<)XP<>t1(l3mq#{{Ii&)aAqQfJJ+hD)xlJ%adQLY*>Syi)gZbag?-e*k~ zw2G0d8u1cbO3M{R&ZkpoWnD)A zW0QTSTPZ2UDwr^ug`C^||3cEJD5ZQ|S_!!b9Mq+i&`by+RFF?8T1Dcv*d8qk7Rzg; zeJM3oM*6$lj=f!nPs}!~TD?Z%*4tJ~koXYftPCp%{Qt{%Sgt@)2B-iMD7tHOUQ3s3 ziRsEC!$7BvYmIByH8ro7I4)e(4XtQphXJMf`r;8d&%{!2&kBjZhNU~X(VR1J8%^c+ zNL*XU6ZS@YQGs{E;^9N!J4C<8$0F@fcSs0(gMrxWhKDw8?@P;S6?j^mi6s$et19C% zRt;mF-L$!7o5Zb>4MWZqiU3RxjrGc5lBE)mw{sUrz8D1vBgIcm4QhE zYY6gS=L;t({65>C8TrI!!NYrD@xB;P5flR~JGq8bYT{u=rQ9jB;j)Wed*fX&Wa% zgvI*=w~r6`qXFLS4SB?la4;fxeSj$(HL*l^SFpCgs40uMpfKk+fkU*2MvEB~rysWx zJ8kF1cJ*k*la>6%~~<1L>}00j60gAN{*z zZ#F(Dug&I$&~Uym3@s5ir<7PO+fCgM({MCXK&9R)%sd#!;R=!slmLIGUMHZ9>>D`? zBXGTF0!wTj-Mk0Zf0QTyS1|CWpd3d`lxlA>SW9^VF5##*AP6qOldSk)>GcZpTghq~u#Z;wKm;aOnzcilWR>!nx86>zuJ zoi|YDlR-fkgdR%Dr%+16ROqv3pPqasg$LI0`k7ZZ!v8UhMSUcuRZI$Jo*g}gOVIph zsMUxi#5N*GB#9z%fw)LqA-;1Ej#fvPBjeB=(~b`u7af-z*Bp1r6=Wm1ljO<$WH)J$ zW8@_HD)~D35qXKcOkN>BC$ExUlefrU$ls_n)Ml!kic<+HNsUue)JxPW)HL-5^$vB3 z`i%O5`jWau{YW!3M>o-Jbb#JRXX!j$rccr@(iiC0=r`&2=@01}^ey@)`ZoPDeTV*) z{*!5Ab}=r-%j{*sOoZuX5=@2}V4i1QX5M2yX09?fneUn3nLpT#>=t$hyPNg0J~qgP z*f8704zZK$Id+PDfxW=K#a?DVWv{SbvDeve+1t+L&K1rkXER|g(qZky`T2c*bFTjj DXOOt9 literal 0 HcmV?d00001 diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift index 5d64b06..260d93b 100644 --- a/OSGKeyboard/Services/AIAgentShortcutRunner.swift +++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift @@ -1,9 +1,8 @@ // AIAgentShortcutRunner.swift // OSGKeyboard · Main App // -// Consumes the keyboard's pending export-skill payload and opens the -// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add -// x-callback URLs so Console can record success / error / cancel. +// Consumes the keyboard's pending export-skill payload. Navigate opens a +// map URL in the host. Other exports open the companion Shortcut. import UIKit import OSGKeyboardShared @@ -14,19 +13,23 @@ enum AIAgentShortcutRunner { AIAgentShortcutRun.trace("host.runPending begin") guard let payload = AppGroupStore().consumePendingShortcutRun() else { return } let catalog = AppGroupStore().agentUserSkillCatalog - guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog), - let name = skill.shortcutName else { + guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else { AIAgentShortcutRun.trace( "host.runPending skip unknownSkill=\(payload.skillID)" ) return } - guard let text = shortcutText(for: skill, payload: payload) else { + if skill.id == AIClipboardSkillCatalog.navigateID { + openMap(for: payload) + return + } + guard let name = skill.shortcutName else { AIAgentShortcutRun.trace( - "host.runPending skip textBuildFailed skill=\(payload.skillID)" + "host.runPending skip missingShortcut skill=\(payload.skillID)" ) return } + let text = payload.joinedTitles AIAgentShortcutRun.traceBody("host.titlesToShortcut", text) guard let url = shortcutsURL(name: name, text: text) else { AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)") @@ -62,19 +65,22 @@ enum AIAgentShortcutRunner { ) } - /// Navigate: pick 高德 → 百度 → Apple Maps, pass that URL to the Shortcut. - /// Other export skills send the parsed lines unchanged. - private static func shortcutText( - for skill: AIClipboardSkill, - payload: AIAgentShortcutRunPayload - ) -> String? { - if skill.id == AIClipboardSkillCatalog.navigateID { - return AIMapNavigation.shortcutInput( - from: payload.joinedTitles, - canOpen: { UIApplication.shared.canOpenURL($0) } - ) + /// 高德 → 百度 → Apple Maps. Do not bounce through Shortcuts. + private static func openMap(for payload: AIAgentShortcutRunPayload) { + guard let urlString = AIMapNavigation.shortcutInput( + from: payload.joinedTitles, + canOpen: { UIApplication.shared.canOpenURL($0) } + ), let url = URL(string: urlString) else { + AIAgentShortcutRun.trace("host.runPending skip navigateURLBuildFailed") + return + } + AIAgentShortcutRun.trace( + "host.openMap scheme=\(url.scheme ?? "") host=\(url.host ?? "")" + ) + AIAgentShortcutRun.traceBody("host.mapURL", urlString) + UIApplication.shared.open(url) { success in + AIAgentShortcutRun.trace("host.openMap result success=\(success)") } - return payload.joinedTitles } private static func shortcutsURL(name: String, text: String) -> URL? { diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index 5b72805..c699c65 100644 --- a/OSGKeyboard/Views/Components/MinimalTabBar.swift +++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift @@ -15,23 +15,6 @@ enum AppTab: Int, CaseIterable { case styles case settings - var icon: MaterialIconName { - switch self { - case .keyboard: return .keyboard - case .skills, .styles: return .menuBook // unused — these tabs use SF Symbols - case .settings: return .settings - } - } - - /// SF Symbol overrides shared with the Mac and iPad sidebars. - var sfSymbol: String? { - switch self { - case .skills: return "sparkles" - case .styles: return "text.badge.star" - default: return nil - } - } - var accessibilityKey: LocalizedStringKey { switch self { case .keyboard: return "tab.keyboard" @@ -41,15 +24,28 @@ enum AppTab: Int, CaseIterable { } } + /// Phone dock: outline when idle, fill when selected (system tab-bar + /// convention). `wand.and.sparkles` has no `.fill` pair. + func dockSystemImage(selected: Bool) -> String { + switch self { + case .keyboard: return selected ? "keyboard.fill" : "keyboard" + case .skills: return "wand.and.sparkles" + case .styles: return selected ? "dial.high.fill" : "dial.high" + case .settings: return selected ? "gearshape.fill" : "gearshape" + } + } + /// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title). var sidebarTitle: LocalizedStringKey { accessibilityKey } - var sidebarSystemImage: String { + /// iPad sidebar. Home stays `house` in both states; other tabs follow + /// the same outline / fill pairing as the phone dock. + func sidebarSystemImage(selected: Bool) -> String { switch self { case .keyboard: return "house" - case .skills: return "sparkles" - case .styles: return "text.badge.star" - case .settings: return "gearshape" + case .skills: return "wand.and.sparkles" + case .styles: return selected ? "dial.high.fill" : "dial.high" + case .settings: return selected ? "gearshape.fill" : "gearshape" } } } @@ -69,14 +65,8 @@ struct MinimalTabBar: View { } } label: { VStack(spacing: 2) { - Group { - if let sfSymbol = tab.sfSymbol { - Image(systemName: sfSymbol) - .font(.system(size: TabBarDockMetrics.iconSize, weight: .regular)) - } else { - MaterialIcon(name: tab.icon, size: TabBarDockMetrics.iconSize) - } - } + Image(systemName: tab.dockSystemImage(selected: selection == tab)) + .font(.system(size: TabBarDockMetrics.iconSize, weight: .regular)) Text(tab.accessibilityKey) .font(TypeStyle.caption2) .lineLimit(1) 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/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/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index cb7d991..d684e4b 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -457,6 +457,8 @@ "skills.extractTodos.description" = "Extract to-dos from copied text into Reminders."; "skills.extractEvents.name" = "Extract events"; "skills.extractEvents.description" = "Extract events from copied text into Calendar."; +"skills.saveToNotes.name" = "Save to Notes"; +"skills.saveToNotes.description" = "Create a new note from the copied text. The title comes from the time and content; the body stays as copied."; "skills.navigate.name" = "Navigate"; "skills.navigate.description" = "Find an address in the copied text and start driving directions. Uses Amap if installed, then Baidu Maps, then Apple Maps."; "skills.install.lead" = "Add it, then confirm. Don’t rename it."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 87a21f0..122ed85 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -456,6 +456,8 @@ "skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。"; "skills.extractEvents.name" = "提取日程"; "skills.extractEvents.description" = "从复制内容提取日程,写入日历。"; +"skills.saveToNotes.name" = "存入备忘录"; +"skills.saveToNotes.description" = "根据复制内容和当前时间生成标题,写入一条新的备忘录;正文保持原文。"; "skills.navigate.name" = "导航"; "skills.navigate.description" = "从复制内容识别地址并开始驾车导航。优先高德,其次百度,最后 Apple 地图。"; "skills.install.lead" = "添加后点「我已添加」,请勿改名。"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 9d13838..d833072 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -773,10 +773,30 @@ public final class KeyboardViewController: UIInputViewController { private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole { switch returnKeyType { - case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall: - return .send - case .default, .next, .done: + case .default: return .newline + case .go: + return .go + case .google: + return .google + case .join: + return .join + case .next: + return .next + case .route: + return .route + case .search: + return .search + case .send: + return .send + case .yahoo: + return .yahoo + case .done: + return .done + case .emergencyCall: + return .emergencyCall + case .continue: + return .continue @unknown default: return .newline } diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift index b0ea8e9..2b23b86 100644 --- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift +++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift @@ -70,13 +70,18 @@ final class AIKeyboardCoordinator { func submitClipboardSkill(_ skill: AIClipboardSkill) { guard canAcceptIdleSubmit else { return } enterIfNeeded() - state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil + let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + if skill.kind == .export { + state.pendingClipboardSkillID = skill.id + state.pendingClipboardSkillSource = material + } else { + clearPendingExportSkill() + } let instruction = AIClipboardSkillCatalog.instruction( for: skill, locale: AIHintLocaleResolver.packLocale(), translationTargetLocaleId: state.translationTargetLocaleId ) - let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)") if let material { AIAgentShortcutRun.traceBody("keyboard.clipboard", material) @@ -120,7 +125,7 @@ final class AIKeyboardCoordinator { ) { guard case .ready(let prompt) = resolution else { // The clipboard window closed between rendering and this tap. - state.pendingClipboardSkillID = nil + clearPendingExportSkill() state.aiSession.fail( ExtL10n.string("keyboard.ai.error.clipboardUnavailable"), utteranceID: nil @@ -128,7 +133,7 @@ final class AIKeyboardCoordinator { return } guard let conversationID = state.aiSession.conversationID else { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() return } let disposition = flow.submitAIQuestion( @@ -137,14 +142,14 @@ final class AIKeyboardCoordinator { thinkingEnabled: thinkingEnabled ) if case .rejected(let rejection) = disposition { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() state.aiSession.fail(message(for: rejection), utteranceID: nil) } } func cancel() { guard state.aiSession.isBusy else { return } - state.pendingClipboardSkillID = nil + clearPendingExportSkill() flow.cancelAIRecording() state.aiSession.cancelCurrentWork() } @@ -153,7 +158,7 @@ final class AIKeyboardCoordinator { if state.aiSession.canInsert, let answer = state.aiSession.answer { guard insertAnswer(answer) else { return } state.aiSession.markAnswerInserted( - offersSend: state.returnKeyRole == .send + offersSend: state.returnKeyRole.usesActionFill ) } else if state.aiSession.canSend { state.aiSession.markAnswerSent() @@ -228,16 +233,21 @@ final class AIKeyboardCoordinator { } func fail(_ message: String, utteranceID: UUID?) { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() state.aiSession.fail(message, utteranceID: utteranceID) } private func endConversationIfNeeded() { - state.pendingClipboardSkillID = nil + clearPendingExportSkill() guard let conversationID = state.aiSession.conversationID else { return } flow.endAIConversation(conversationID) } + private func clearPendingExportSkill() { + state.pendingClipboardSkillID = nil + state.pendingClipboardSkillSource = nil + } + private var isPendingExportSkill: Bool { guard let id = state.pendingClipboardSkillID else { return false } return resolvedSkill(id: id)?.kind == .export @@ -251,9 +261,10 @@ final class AIKeyboardCoordinator { } /// Parse an export skill. Empty → in-keyboard tip, stay in the host app. - /// Lines → hand off to the host to run the companion Shortcut. + /// Lines → hand off to the host (Shortcut, Maps, or Didi). private func finishExportSkill(answer: String) { - let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text + let source = state.pendingClipboardSkillSource + ?? ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text let skillID = state.pendingClipboardSkillID let items: [String] let emptyTipKey: String @@ -267,6 +278,13 @@ final class AIKeyboardCoordinator { case AIClipboardSkillCatalog.navigateID: items = AIAddressExtraction.lines(from: answer, sourceClipboard: source) emptyTipKey = "keyboard.ai.skill.noAddress" + case AIClipboardSkillCatalog.saveToNotesID: + items = AINoteExport.items( + from: answer, + sourceClipboard: source, + locale: AIHintLocaleResolver.packLocale() + ) + emptyTipKey = "keyboard.ai.skill.noNote" default: items = AIGenericSkillExport.items(from: answer) emptyTipKey = "keyboard.ai.skill.noExportItems" @@ -280,7 +298,7 @@ final class AIKeyboardCoordinator { AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n")) } #endif - state.pendingClipboardSkillID = nil + clearPendingExportSkill() if state.aiSession.isBusy { state.aiSession.cancelCurrentWork() } @@ -292,14 +310,25 @@ final class AIKeyboardCoordinator { state.skillTipText = ExtL10n.string(emptyTipKey) return } - guard let skillID, - resolvedSkill(id: skillID)?.shortcutName != nil else { - AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")") + guard let skillID, let skill = resolvedSkill(id: skillID) else { + AIAgentShortcutRun.trace("keyboard.parse missingSkill") + state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") + return + } + if skill.requiresShortcut, skill.shortcutName == nil { + AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID)") state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") return } AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)") - state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut") + let tipKey: String + switch skillID { + case AIClipboardSkillCatalog.navigateID: + tipKey = "keyboard.ai.skill.openingMaps" + default: + tipKey = "keyboard.ai.skill.runningShortcut" + } + state.skillTipText = ExtL10n.string(tipKey) state.runClipboardExportSkill(skillID, items) } 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 6faa324..d4850e7 100644 --- a/OSGKeyboardExt/Views/AIKeyboardView.swift +++ b/OSGKeyboardExt/Views/AIKeyboardView.swift @@ -518,6 +518,9 @@ struct AIKeyboardView: View { if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID { return ExtL10n.string("keyboard.ai.skill.extractingAddress") } + if state.pendingClipboardSkillID == AIClipboardSkillCatalog.saveToNotesID { + return ExtL10n.string("keyboard.ai.skill.namingNote") + } if let draft = state.aiSession.draftAnswerText, !draft.isEmpty { return ExtL10n.string("keyboard.ai.generating") } 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 75129b8..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"; @@ -299,6 +307,7 @@ "keyboard.ai.skill.translate" = "Translate"; "keyboard.ai.skill.extractTodos" = "Tasks"; "keyboard.ai.skill.extractEvents" = "Events"; +"keyboard.ai.skill.saveToNotes" = "Notes"; "keyboard.ai.skill.navigate" = "Navigate"; "keyboard.ai.skill.previewPolish" = "Polish"; "keyboard.ai.skill.previewIdeas" = "Ideas"; @@ -306,10 +315,13 @@ "keyboard.ai.skill.noTodos" = "No tasks in the clipboard"; "keyboard.ai.skill.noEvents" = "No date or time in the clipboard"; "keyboard.ai.skill.noAddress" = "No address in the clipboard"; +"keyboard.ai.skill.noNote" = "Nothing to save to Notes"; "keyboard.ai.skill.noExportItems" = "Nothing to send to the Shortcut"; "keyboard.ai.skill.extracting" = "Finding tasks…"; "keyboard.ai.skill.extractingEvents" = "Finding events…"; "keyboard.ai.skill.extractingAddress" = "Finding an address…"; +"keyboard.ai.skill.namingNote" = "Naming the note…"; "keyboard.ai.skill.runningShortcut" = "Running Shortcut…"; +"keyboard.ai.skill.openingMaps" = "Opening Maps…"; "keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills"; "keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index f40f409..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" = "支持本地转写"; @@ -299,6 +307,7 @@ "keyboard.ai.skill.translate" = "翻译"; "keyboard.ai.skill.extractTodos" = "待办"; "keyboard.ai.skill.extractEvents" = "日程"; +"keyboard.ai.skill.saveToNotes" = "备忘录"; "keyboard.ai.skill.navigate" = "导航"; "keyboard.ai.skill.previewPolish" = "润色"; "keyboard.ai.skill.previewIdeas" = "点子"; @@ -306,10 +315,13 @@ "keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项"; "keyboard.ai.skill.noEvents" = "剪贴板内容没有日期或时间"; "keyboard.ai.skill.noAddress" = "剪贴板内容没有可导航的地址"; +"keyboard.ai.skill.noNote" = "剪贴板没有可保存的内容"; "keyboard.ai.skill.noExportItems" = "没有可发给捷径的内容"; "keyboard.ai.skill.extracting" = "正在提取待办…"; "keyboard.ai.skill.extractingEvents" = "正在提取日程…"; "keyboard.ai.skill.extractingAddress" = "正在识别地址…"; +"keyboard.ai.skill.namingNote" = "正在生成标题…"; "keyboard.ai.skill.runningShortcut" = "正在运行捷径…"; +"keyboard.ai.skill.openingMaps" = "正在打开地图…"; "keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装"; "keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径"; 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/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/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift index 206e4c8..0e5b556 100644 --- a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift +++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift @@ -25,6 +25,8 @@ public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable { public enum AIAgentShortcutRun { public static let pendingKey = "config.aiAgentSkills.pendingRun.v1" + /// Last skill-handoff lines, readable from the App Group plist on device. + public static let recentTracesKey = "diag.skills.recent.v1" /// Drop payloads older than this; a leftover write must not fire later. public static let payloadTTL: TimeInterval = 60 @@ -65,6 +67,19 @@ public enum AIAgentShortcutRun { /// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies. public static func trace(_ message: String) { OSGDiag.log(message, category: "skills") + persistTrace(message) + } + + /// Keep a short ring so we can copy traces off the phone without root `log collect`. + private static func persistTrace(_ message: String) { + guard let defaults = AppGroup.defaultsIfAvailable else { return } + var lines = defaults.stringArray(forKey: recentTracesKey) ?? [] + let stamp = ISO8601DateFormatter().string(from: Date()) + lines.append("\(stamp) \(message)") + if lines.count > 60 { + lines = Array(lines.suffix(60)) + } + defaults.set(lines, forKey: recentTracesKey) } /// Single-line preview so Console keeps the format (`\\n` for newlines). diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift index bbf1764..1ddb5fb 100644 --- a/OSGKeyboardShared/Services/AIClipboardSkill.swift +++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift @@ -4,7 +4,7 @@ // Built-in clipboard actions for AI idle. The catalog is an ordered list so // Settings / the Skills tab can persist a subset or permutation without // changing the view. Transform skills insert into the current field; -// export skills hand off to a companion Shortcut after the model runs. +// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi). import Foundation @@ -39,7 +39,9 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable { /// Built-in skills are always false. Custom skills default off. public let thinkingEnabled: Bool - public var requiresShortcut: Bool { kind == .export } + /// Reminders, Calendar, and Notes exports need a companion Shortcut. + /// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut. + public var requiresShortcut: Bool { kind == .export && shortcutName != nil } public var isUserCreated: Bool { id.hasPrefix("user.") } public init( @@ -80,22 +82,18 @@ public enum AIClipboardSkillCatalog: Sendable { public static let summarizeID = "summarize" public static let translateID = "translate" public static let extractTodosID = "extractTodos" - public static let extractTodosShortcutName = "OSG · 提取待办" - public static let extractTodosShortcutICloudURL = URL( - string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44" - )! + public static let extractTodosShortcutName = "OSGExtractTodos" public static let extractTodosResourceName = "OSGExtractTodos" public static let extractEventsID = "extractEvents" - public static let extractEventsShortcutName = "OSG · 提取日程" - public static let extractEventsShortcutICloudURL = URL( - string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf" - )! + public static let extractEventsShortcutName = "OSGExtractEvents" public static let extractEventsResourceName = "OSGExtractEvents" + public static let saveToNotesID = "saveToNotes" + public static let saveToNotesShortcutName = "OSGSaveToNotes" + public static let saveToNotesResourceName = "OSGSaveToNotes" + public static let navigateID = "navigate" - public static let navigateShortcutName = "OSG · 导航" - public static let navigateResourceName = "OSGNavigate" /// Full built-in catalog, in a stable display order for the Skills tab. public static let catalog: [AIClipboardSkill] = [ @@ -135,7 +133,6 @@ public enum AIClipboardSkillCatalog: Sendable { kind: .export, isDefault: false, shortcutName: extractTodosShortcutName, - shortcutICloudURL: extractTodosShortcutICloudURL, shortcutResourceName: extractTodosResourceName ), AIClipboardSkill( @@ -147,9 +144,19 @@ public enum AIClipboardSkillCatalog: Sendable { kind: .export, isDefault: false, shortcutName: extractEventsShortcutName, - shortcutICloudURL: extractEventsShortcutICloudURL, shortcutResourceName: extractEventsResourceName ), + AIClipboardSkill( + id: saveToNotesID, + systemImage: "note.text", + titleKey: "keyboard.ai.skill.saveToNotes", + cardTitleKey: "skills.saveToNotes.name", + descriptionKey: "skills.saveToNotes.description", + kind: .export, + isDefault: false, + shortcutName: saveToNotesShortcutName, + shortcutResourceName: saveToNotesResourceName + ), AIClipboardSkill( id: navigateID, systemImage: "arrow.triangle.turn.up.right.diamond.fill", @@ -157,9 +164,7 @@ public enum AIClipboardSkillCatalog: Sendable { cardTitleKey: "skills.navigate.name", descriptionKey: "skills.navigate.description", kind: .export, - isDefault: false, - shortcutName: navigateShortcutName, - shortcutResourceName: navigateResourceName + isDefault: false ), ] @@ -263,6 +268,8 @@ public enum AIClipboardSkillCatalog: Sendable { """ case extractEventsID: return eventInstruction(zh: zh, now: now) + case saveToNotesID: + return noteInstruction(zh: zh, now: now) case navigateID: return navigateInstruction(zh: zh) default: @@ -272,6 +279,26 @@ public enum AIClipboardSkillCatalog: Sendable { } } + /// Title only. The original clipboard is the note body; do not ask the + /// model to rewrite it. + private static func noteInstruction(zh: Bool, now: Date) -> String { + let clock = clockContext(now: now, zh: zh) + if zh { + return """ + \(clock) + 请根据剪贴板正文写一个简短备忘录标题。只要一行标题,不要输出正文,不要编号、不要引号、不要解释。标题中不要出现换行或 |。最多 40 个字。 + 标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。 + 即使原文很短也要给一个标题。不要输出 NONE。 + """ + } + return """ + \(clock) + Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters. + The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body. + Always return a title, even when the clipboard is short. Do not output NONE. + """ + } + private static func navigateInstruction(zh: Bool) -> String { if zh { return """ diff --git a/OSGKeyboardShared/Services/AIGenericSkillExport.swift b/OSGKeyboardShared/Services/AIGenericSkillExport.swift index f246c20..97b5c8d 100644 --- a/OSGKeyboardShared/Services/AIGenericSkillExport.swift +++ b/OSGKeyboardShared/Services/AIGenericSkillExport.swift @@ -2,7 +2,7 @@ // OSGKeyboard · Shared // // Line-oriented Shortcut input for user-created export skills. Built-in -// extract-todos / extract-events keep their dedicated parsers. +// extract-todos / extract-events / save-to-notes keep their dedicated parsers. import Foundation diff --git a/OSGKeyboardShared/Services/AIMapNavigation.swift b/OSGKeyboardShared/Services/AIMapNavigation.swift index 03a434b..fac9c9a 100644 --- a/OSGKeyboardShared/Services/AIMapNavigation.swift +++ b/OSGKeyboardShared/Services/AIMapNavigation.swift @@ -2,8 +2,7 @@ // OSGKeyboard · Shared // // Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu → -// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that -// check and passes the resulting URL to the companion Shortcut. +// Apple Maps. The host calls `canOpenURL` and opens the URL itself. import Foundation 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/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 6e923ee..6cdb59f 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -144,6 +144,9 @@ public final class KeyboardState: ObservableObject { @Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs /// Export skill currently waiting on the LLM. Nil for transform skills. @Published public var pendingClipboardSkillID: String? + /// Clipboard captured when that export skill was tapped, so the body + /// still exists after the 30-second hint window closes. + public var pendingClipboardSkillSource: String? /// In-keyboard toast (e.g. no todos). Does not leave the host app. @Published public var skillTipText: String? /// Host field is a password / secure entry — never read pasteboard. @@ -219,13 +222,38 @@ public final class KeyboardState: ObservableObject { public enum ReturnKeyRole: Equatable { case newline case send + case go + case search + case join + case done + case next + case `continue` + case route + case google + case yahoo + case emergencyCall public var titleKey: String { switch self { case .newline: return "common.newline" - case .send: return "common.send" + case .send: return "common.send" + case .go: return "keyboard.return.go" + case .search: return "keyboard.return.search" + case .join: return "keyboard.return.join" + case .done: return "common.done" + case .next: return "keyboard.return.next" + case .continue: return "common.continue" + case .route: return "keyboard.return.route" + case .google: return "keyboard.return.google" + case .yahoo: return "keyboard.return.yahoo" + case .emergencyCall: return "keyboard.return.emergencyCall" } } + + /// Green action chrome (system uses blue for Go / Search / Send / Done). + public var usesActionFill: Bool { + self != .newline + } } // Action hooks — injected by the view controller at install time. 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/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift index 7115402..aa01e59 100644 --- a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift +++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift @@ -163,12 +163,24 @@ final class AIAgentSkillLayoutTests: XCTestCase { XCTAssertNil(AIAgentShortcutRun.decode(data)) } - func testShortcutsRunURLEncodesNameAndText() { - let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSG · 提取待办", text: "买牛奶\n回邮件") + func testShortcutsRunURLPreservesNotesFieldSeparator() { + let text = "周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项" + let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSGSaveToNotes", text: text) + let raw = url?.absoluteString ?? "" + XCTAssertFalse(raw.contains("<"), "angle brackets in the URL get stripped by Shortcuts") + XCTAssertFalse(raw.contains(">")) + let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "text" }?.value, text) + XCTAssertTrue(text.contains("||OSG_NOTE||")) + } + + func testShortcutsRunURLIncludesNameAndText() { + let shortcutName = AIClipboardSkillCatalog.extractTodosShortcutName + let url = AIAgentShortcutRun.shortcutsRunURL(name: shortcutName, text: "买牛奶\n回邮件") XCTAssertEqual(url?.scheme, "shortcuts") XCTAssertEqual(url?.host, "run-shortcut") let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] - XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSG · 提取待办") + XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGExtractTodos") XCTAssertEqual(items.first { $0.name == "input" }?.value, "text") XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件") XCTAssertNil(items.first { $0.name == "x-success" }) @@ -176,7 +188,7 @@ final class AIAgentSkillLayoutTests: XCTestCase { func testXCallbackRunURLUsesCallbackHost() { let url = AIAgentShortcutRun.shortcutsRunURL( - name: "OSG · 提取待办", + name: AIClipboardSkillCatalog.extractTodosShortcutName, text: "买牛奶", xSuccess: "osgkeyboard://skill/shortcut-result?status=success", xError: "osgkeyboard://skill/shortcut-result?status=error", @@ -197,51 +209,67 @@ final class AIAgentSkillLayoutTests: XCTestCase { XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件") } - func testExtractTodosUsesICloudShareLink() { + func testExtractTodosUsesBundledShortcut() { let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID) - XCTAssertEqual( - skill?.shortcutICloudURL, - AIClipboardSkillCatalog.extractTodosShortcutICloudURL - ) - XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com") - XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办") + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos") XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos") } - func testExtractEventsUsesICloudShareLink() { + func testExtractEventsUsesBundledShortcut() { let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID) - XCTAssertEqual( - skill?.shortcutICloudURL, - AIClipboardSkillCatalog.extractEventsShortcutICloudURL - ) - XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com") - XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程") + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents") XCTAssertEqual( skill?.shortcutResourceName, "OSGExtractEvents" ) XCTAssertEqual(skill?.systemImage, "calendar") XCTAssertFalse(skill?.isDefault ?? true) - XCTAssertEqual( - AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!), - "1f4afcf7ee22400cbf84e319d969aadf" - ) } - func testNavigateUsesBundledShortcut() { + func testNavigateDoesNotRequireShortcut() { let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID) + XCTAssertNil(skill?.shortcutName) XCTAssertNil(skill?.shortcutICloudURL) - XCTAssertEqual(skill?.shortcutName, "OSG · 导航") - XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate") + XCTAssertNil(skill?.shortcutResourceName) XCTAssertEqual( skill?.systemImage, "arrow.triangle.turn.up.right.diamond.fill" ) XCTAssertFalse(skill?.isDefault ?? true) XCTAssertEqual(skill?.kind, .export) + XCTAssertFalse(skill?.requiresShortcut ?? true) + } + + func testNavigateEnablesWithoutShortcutConfirmation() { + let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) + XCTAssertEqual( + store.enable(AIClipboardSkillCatalog.navigateID), + .enabled + ) + XCTAssertTrue(store.layout.isEnabled(AIClipboardSkillCatalog.navigateID)) + } + + func testSaveToNotesUsesBundledShortcutUntilICloudShareExists() { + let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.saveToNotesID) + XCTAssertNil(skill?.shortcutICloudURL) + XCTAssertEqual(skill?.shortcutName, "OSGSaveToNotes") + XCTAssertEqual(skill?.shortcutResourceName, "OSGSaveToNotes") + XCTAssertEqual(skill?.systemImage, "note.text") + XCTAssertFalse(skill?.isDefault ?? true) XCTAssertTrue(skill?.requiresShortcut ?? false) } + func testCannotEnableSaveToNotesBeforeShortcutConfirmation() { + let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) + XCTAssertEqual( + store.enable(AIClipboardSkillCatalog.saveToNotesID), + .needsShortcut + ) + XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID)) + } + func testICloudShareLinkMapsToShortcutsInstallURL() { let share = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")! let url = AIAgentShortcutRun.shortcutsInstallURL(from: share) diff --git a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift index a27bada..ee148bb 100644 --- a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift +++ b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift @@ -265,4 +265,15 @@ final class AIClipboardSkillTests: XCTestCase { XCTAssertTrue(prompt.contains("NONE")) XCTAssertTrue(prompt.contains("起点|终点")) } + + func testSaveToNotesAsksForTitleNotBody() { + let prompt = AIClipboardSkillCatalog.instruction( + skillID: AIClipboardSkillCatalog.saveToNotesID, + locale: "zh", + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId + ) + XCTAssertTrue(prompt.contains("标题")) + XCTAssertTrue(prompt.contains("不要输出正文")) + XCTAssertTrue(prompt.contains("不要输出 NONE")) + } } 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/Scripts/generate_extract_events_shortcut.py b/Scripts/generate_extract_events_shortcut.py index b6141e3..7abafa1 100644 --- a/Scripts/generate_extract_events_shortcut.py +++ b/Scripts/generate_extract_events_shortcut.py @@ -26,6 +26,7 @@ ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" UNSIGNED = OUT_DIR / "OSGExtractEvents.unsigned.shortcut" SIGNED = OUT_DIR / "OSGExtractEvents.shortcut" +SHORTCUT_NAME = "OSGExtractEvents" OBJECT_REPLACEMENT = "\ufffc" @@ -156,7 +157,7 @@ def workflow() -> dict: "WFWorkflowClientRelease": "26.0", "WFWorkflowMinimumClientVersion": 900, "WFWorkflowMinimumClientVersionString": "900", - "WFWorkflowName": "OSG · 提取日程", + "WFWorkflowName": SHORTCUT_NAME, "WFWorkflowHasShortcutInputVariables": True, "WFWorkflowHasOutputFallback": False, "WFWorkflowImportQuestions": [], diff --git a/Scripts/generate_extract_todos_shortcut.py b/Scripts/generate_extract_todos_shortcut.py index 33ae6ab..089e7d3 100644 --- a/Scripts/generate_extract_todos_shortcut.py +++ b/Scripts/generate_extract_todos_shortcut.py @@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" UNSIGNED = OUT_DIR / "OSGExtractTodos.unsigned.shortcut" SIGNED = OUT_DIR / "OSGExtractTodos.shortcut" +SHORTCUT_NAME = "OSGExtractTodos" OBJECT_REPLACEMENT = "\ufffc" @@ -77,7 +78,7 @@ def workflow() -> dict: "WFWorkflowClientRelease": "26.0", "WFWorkflowMinimumClientVersion": 900, "WFWorkflowMinimumClientVersionString": "900", - "WFWorkflowName": "OSG · 提取待办", + "WFWorkflowName": SHORTCUT_NAME, "WFWorkflowHasShortcutInputVariables": True, "WFWorkflowHasOutputFallback": False, "WFWorkflowImportQuestions": [], diff --git a/Scripts/generate_navigate_shortcut.py b/Scripts/generate_navigate_shortcut.py deleted file mode 100644 index 55567f5..0000000 --- a/Scripts/generate_navigate_shortcut.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""Build the companion Shortcut for the Navigate skill. - -Writes an unsigned binary plist that `shortcuts sign` can notarize. - -The host already picked 高德 → 百度 → Apple Maps and passes one URL. -This recipe only materializes Shortcut Input as Text and opens it: - - Text (Shortcut Input) - → Open URLs -""" - -from __future__ import annotations - -import plistlib -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -OUT_DIR = ROOT / "OSGKeyboard" / "Resources" / "Shortcuts" -UNSIGNED = OUT_DIR / "OSGNavigate.unsigned.shortcut" -SIGNED = OUT_DIR / "OSGNavigate.shortcut" - -OBJECT_REPLACEMENT = "\ufffc" - -TEXT_UUID = "C1D2E3F0-1111-4E00-8A5A-FD7E1B0C396F" -OPEN_UUID = "C1D2E3F1-2222-4F11-9A6B-0E8F2C1D4A70" - - -def token_string(attachment_value: dict) -> dict: - return { - "Value": { - "attachmentsByRange": {"{0, 1}": attachment_value}, - "string": OBJECT_REPLACEMENT, - }, - "WFSerializationType": "WFTextTokenString", - } - - -def shortcut_input_text() -> dict: - return token_string({"Type": "ExtensionInput"}) - - -def action_output_text(output_name: str, output_uuid: str) -> dict: - return token_string( - { - "Aggrandizements": [], - "OutputName": output_name, - "OutputUUID": output_uuid, - "Type": "ActionOutput", - } - ) - - -def workflow() -> dict: - return { - "WFWorkflowClientVersion": "3600.0.4", - "WFWorkflowClientRelease": "26.0", - "WFWorkflowMinimumClientVersion": 900, - "WFWorkflowMinimumClientVersionString": "900", - "WFWorkflowName": "OSG · 导航", - "WFWorkflowHasShortcutInputVariables": True, - "WFWorkflowHasOutputFallback": False, - "WFWorkflowImportQuestions": [], - "WFWorkflowTypes": [], - "WFWorkflowInputContentItemClasses": [ - "WFStringContentItem", - "WFURLContentItem", - "WFRichTextContentItem", - ], - "WFWorkflowOutputContentItemClasses": [], - "WFWorkflowNoInputBehavior": { - "Name": "WFWorkflowNoInputBehaviorAskForInput", - "Parameters": { - "ItemClass": "WFStringContentItem", - }, - }, - "WFWorkflowIcon": { - "WFWorkflowIconGlyphNumber": 59723, - "WFWorkflowIconStartColor": 4278222847, - }, - "WFWorkflowActions": [ - { - "WFWorkflowActionIdentifier": "is.workflow.actions.gettext", - "WFWorkflowActionParameters": { - "UUID": TEXT_UUID, - "CustomOutputName": "快捷指令文本", - "WFTextActionText": shortcut_input_text(), - }, - }, - { - "WFWorkflowActionIdentifier": "is.workflow.actions.openurl", - "WFWorkflowActionParameters": { - "UUID": OPEN_UUID, - "WFURL": action_output_text("快捷指令文本", TEXT_UUID), - "WFShowWhenRun": False, - }, - }, - ], - } - - -def main() -> None: - OUT_DIR.mkdir(parents=True, exist_ok=True) - UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY)) - print(f"wrote {UNSIGNED}") - print( - "sign with: shortcuts sign --mode anyone " - f"--input {UNSIGNED} --output {SIGNED}" - ) - - -if __name__ == "__main__": - main() 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/project.yml b/project.yml index 4671e18..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: "69" + CURRENT_PROJECT_VERSION: "71" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target @@ -96,7 +96,7 @@ targets: - path: OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut type: file buildPhase: resources - - path: OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut + - path: OSGKeyboard/Resources/Shortcuts/OSGSaveToNotes.shortcut type: file buildPhase: resources entitlements: