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] 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 zcmV;tC_~rrwgL9G0gy-tdS$`ph6{{) zO))7jCk6vm1Op5pGdDP9W@I*IV=*&0G-5PjI5IUfH)1w2W;bRsF=Ayhld%IMlRyC) zK<~ylTGlC98z`SqtIAJKt(se)QrZ}eaU|zrd7E@&@-;#B!5Trbe^1p{2a_>AmzQi! z9Zh3P)q);TeKW&1m$RAy9sv}09U76BpR5yoG;r(ZYry!|FGI7F%mOn+AP;i3@>X-9 zv6g$~FxG)IN__53_zi^){iHm_En7+7OzB z2u5clDVFOykU~`mX{Z z0Mb5W#Ff!NDg(3^Db*0HniKhWkXKL!X0l&4x&t}!Ka&9pIBD`Ta(|~*N}ITMJ`7Mb z+Dmpd=AV~~6%waf6%c~kZcUsL+u6DTGct$oE-ttK3OkJ|hDB<;t(pL=SNLUwjk7=i zrgHe|aA7aF+*8wPT|*+&wPL^2p?AABl9>m7Pp%{l00000004HAkqIYAVmhud55)zp zKM0pP2tQ5!na`yq*Jqs%vuax@6x$9dBnf8;;G_ZwP$HRyOXvi(EQ87Yk z?RACZKyn#^&DQr+vjGa%Cw~CrA&x6YnVC2FsO=6XGg`9zxDcBqVP1WXiE3|s%LV=C zaW2L{SwnbFo|xsuYqjYH>-RUU&{BP0b8U^NiazAjP5@5FBOPDj z=!6(-;6*utE5K)E)K%a+_FQk>zqZ4lK4Um7t>Lhxo^rIjDW?qS{C}(QMCp{q3ZON( zOWcLRa=Y3VOVPXF9m*G1fX{4-&^!I2({~kb9KNGD?_Tr0@AF1b31}u7EY(3XJ*m9) zhX6aeiQnhik>h8du?24d8EZ_32L8^8@~Ij99PF1xp6wmALETniCEe4itcp*pzg(lY zcfvnRjEDMwD|1kZt$)e!N_AE!2Yf+oL+RPQEn&BO2L)Y=Vs)0V6Fg^9XGp{XA6l3a zFk$&mvFYIoQNWzlc>SWvAYa#->VGJ@p>^Lz*UON zQQ_2a%t$;2729hOu;?AAudfAHeCb5xVKOWnQlV>tSb5XJ5=KK_am$`o8UrAGBJtY0 z3`djD&*#YKKywHA+p`MZvZ_t}I#ZtxI^93a>rQ@s;nnZ$Yle8eR1#I6q2tbf*-H4~ zvbswB@@Nzpd4Ez~hktZtaAo-Ioze7bOy~i&+0sEt9BzmIS45b$i zzLn2>zSj1iGDh*!hv#u;s3SlZ2np8w8-3nWeX$d4)&`R-odut|sZ?H@HS?&A2!Fez;J4jIUpIG=iniZxRW~An z{<-Y&haQyM=4>lDN^2y3cpE~@gw#5rKQYfBn`V0oi5O;w##Fo278M4`>sxVQ2-Fww z5AE`-3NtTdY^IPiB%YUOGv)zt!J6VhhfjhFiUTNC;<)9m(bOq}B23TBcGs9iImA4w9l6 zh?u)GA_p_sYxn8(eZX>?l5CpyjEuaLz1L*q?uBO&IL$CU^fbOX3I~DNb3{0Kp}`O2fQLPQ@GlU!L1<^17FtjKa;*K@;q>xWFMp#aBZT68Ch>>Sz3@~vl>ND4 z!6Fqu76m2XAP@I50R>DkayLORCS{~&s>EXo>=PaRVLCb0tz~)kFPs3oRQBa&J9i`{ zB`JFiu`Wrc9cV$mrsT)^20o;0ZI&^SDSy$jaqqEVcdGLq%&=e6lZG$>wZ8{5Zc0oM-gU8HG83bi2!HgE z*Fh#m`mf))^xHrWQ%}N2aHUi%24Kut@+5+;yPwj6GpPl`W*0#oFZzeTa4~x1q^SPT zTpOOe^C>cDy}}mFx5sjJuLf8%p2x8{wTuW!`07+kZe}(kDCt=dQZi!_D1>G zW6B0CRrayLK1T+)uH!k$nNTkR^jKmzK)R5_iJ(IlXLR?IJQOc`ved<&XT@v z*!)j!7un}C+~!7!e>R}aTR+9*e+ifwO`;jA)}Po_vFMYCD?~StPCGV`({JYP0KuFF z#~r-7P#xT0CSxRbzBJnu(;|sI~cXC%|zMNB%s((htq(i@z0pB^s zV!5;3S~w2;_;5+)522N?WRfd!$T>6zo>Zjr*u82w0)B%4x_`pUr^0^iWu=}1=Mo=$ zmz-#KcDBDI7R}SOM_Nf)>`fBbu%8&-TW(5hP2-pS1uqR`p@t5`x7qmzamJaP^i z8Zh8872N>#{2}pM4_F0=Dj+A&tlGu$$ELfT1>G3a>4~8D5R=|Jy#bcTWT)%dWdp}f zMS`{KoV5<*8h<2feMthQmRXho2k3k{+LrU7%s?7j(t73a@elC%aR#jf2(M#O^RQ{{ z&QD|+N!|-lv9(e7``br4A|APcEf26;1zX>xNAfE#zAVV)&0Xdiw160A#3J}ruHLCo z2GElSOIA;YL8KH~R_efLy{LQsjrJoc2XFg2g3yM6mVf8zs5`DX+&x(cvcdB2+eS>F z`5P9E2Q{@E`wbUm`mg)|;=G+z;(lW<5FIyK?!(cZUJ0R9Vul%y3!A8F@ixOm&ianS zfZ=)!Pd^q71s>23E(UNikpK-;sgSPRBAnc*Ynm9D2S=FHWN1|S3EL}pq6(raSd=7} znaDkSuYW+*u~@kvDft?-8i``s*Jbl69U{OV&V}0|s?eVJluaib0``MEy4Ha-hvO{B)e~I34g%~l}bUU$%=zAd~nywx9;C3V%h<8 ztvf)Z01<(?!Uy?}jqV~x+?L7g-KRj&fN=b4=*hEtI@KF<*2w{4Tv7Yy=g zaern^D`!_YO}X11HDYK_Fs@ma!B`uP3srx6T(}o?b}V+_N**v|Ie>6+*FLVb%(`f{ zKDk=B$_z~@{|9?p+A_@4O@e>S7;uhUL))}onUz!1?xas9L}g=Fb9x~y;*P$-U0UwQu8DHxU#PUVj_+ zd}&NtxjhU5Q2~{yzz$Diso*poe6HaZ49Dz5qUY3~?Ev9T>}uW}zDB`!-YP`J3JPt0 zu4x^r-F7Y;`wLDdY4BFyv>Z(e%qwPap>RvAs3NJaohS?}FW8Uz)GfEDb?sO;kOH*t zPjR3V-OeO@5oMy#2VK(d?9!rl zYlSWA{XFiE)$+(8c@YDCQ_)&A1iOB@W8qX%SiSxItTl3kOxkv878>lghdzp*fYjMS zq5KZ1Kc|q2=$}@nw)^G0jEw^P2WaF_bM>i*%4WGtbvaz=w|~|iehv_4;d_pPGVC|n zUSvN+5IE0a<%7BY$ekI{%XpV!-I)M87>5K5lJ5P}r!w;Ib17fQ{zFv-;D4 zBluTVzymW}@fVZr+PCa|3rZ}P$sV3@`qexHuxv_+&n(BpWKBq#5WhG%DX}1l5pmRZ z%!}hd(X?};*RpHpzli~x%3k(9HX>IN-O;qHZQ!I?$#n?*Wd|LC&VQ<~u1b~MnH{TP zCB-Y0_x5Wr?jbg?b6VnGqRX7sDNH?j8RI6pd3kF@q0j_q>1V9+1PIj5vgwDSuvcYL zdRfq{e7MMN(XfOTE6SymV%2VB$qDL?WT&Qv^FPf8?`C$%p_)+@y!gsryU<%uzjH0A zw)P4)dhE1VLtgrnfPan|NyFG0nF5WHlI^4=q?rb?_BM}hu`s5!qaq% z9>;Hd%KV>Q>gn5YY#Cpo_>oBhCv$H!`DlzgBp~VReK3rFG~!!(Hft9XZlpNcrzasf z@?x1_0E{~Za~qa$LtQ?5lL=JH1laYu{5iuKJEZu6SEEsz?0@Di2v^y!7>y62w@doo zR7*7%Z+yHkkMg&MWDOa~|5=|Nmr1g-lnCSf4wj?rVv}Ax?Y;o3B`uOqnY}REZ)RB7 z!+eC)cxOgJb8u^#SdlRrX8hK5oHZQ&PiB%W9zcAL4BA%!%$7pP4tDMnxICDWXapVl zOd80>U?(9D9e)8~d*EM$*hOvvs>=$dhuhvzg9_mE?#bo7)`)}y9koUlW-vM%`L zlGPu&OjODO*w4vwnrBH|lWAP7_?`kUki?*^2a#b=0iyQc1Z9`%bYNA{p~;|Q8a7~r z?*YwI=xBw;`R$G&GLK{Kp388}KBz)-G{BovP=9jAg(W5TJ2ZI3j8I}tKx_B;uiO5; zxMfBnK9^fwFzLD*7&bExi)UB9rP!AOPRzQ1B+kzl4@D#|5{7ikSXkd3M&+jo+s&STjHhOhlnVM0U+Hj-xn&iW+4C`Y+Q|hFv>K)( zZ+|(!RmB@ick7=-nI%RH%7DxHxl z)?P(TvQ&)7rlHs0vVafHd4tO&=G``fk5B_4tQAR0DLrufV2Tjrg$c~r1J$pr`XY<$yYNVHW~{c;Y-r_DJA z8W6KfNsUHUo@7F^3`?B~BFM@a=?!$HQ{PFHgmc=rcS(6vGn=b$HgID9X{gc%W<5uQ8;(sw| zsw{;MXRgd1=(7@_br(>aT;y+pB%Q6waymU*L)z=2$_wJUdwO)|O`CPN=c$Q=y$9k` zzGz2xBb8)8rtYZ8k%IX;AhM`drQW1S&@>2xD_t8nvp)p*B#RVExUBX?g}~Ab%9{E| zCY(+heXI?-r%i@*T*9j zd$bJ7(SXVR*&z@;8C%&*qZ6+s$W&1=7zbq22){wn@cB^uFh;TxLq=eIB7fm(qq&m5 z?)zmhIu#jYewZ#j;mXDiiblxc=MKXg z?DHMS}o7svt_S23gQeZluN#M8`zo8jzf zWO>)*D6%+HY06*^e_UOHDeI z^B+3tp^|~Mqb)+D({%d0mOui*Uk=u*(TzTvu5k5GmQFS4kAX1X2Z_ei9^E)jsFCb4 zBl;w(ukVX=y2t{ySGDTxY2#X`IJ6!rQsWm~0UcVK>?IZmHeFxqCH>*v^Bj!;q8C8i z-g$CkmH1*iJbUO;NPqby4_rjKlN0+A0gHQnc^gH(*g0T4)y1y6@OE@9+EGuyR)GI| z%Wbj6hDT+F>%vf`&^Xsy71KkiJ}nok+W?yxSLSf~5SwRJ{g26C8$sNSDm$aCJN`O) z!Y;vG!a20$R8enx0V&B%^NN&5aP&Q7JoUC;@J*y{x$4rIA-3}Tpnm{(PJc~HAY?r+OiRhjC_*_Dkk?^H z>A-$uDglVRK=V*wTE(L8t8$LtuWak$X-l*l`Si?AF7L=g$fcUxre?v>DMp0cMVm+h zx{GIOOdvm8gPRBs|9@E{8A}s)T9n`|=cy@wm>-J(DcvwIUA-=5D~IY1!Dg_^ zVuMosz-_5zB<2E7+9Q6ve9m4{wp3(5YdV_}D7v0$ICr6GW6ducb(Y7X$?kTnkii4p z*~T04GH>|8%?vgLw<(^9vb|lcBbL5kvmVJD)_GW9o`u zGzN^g!#6;1B6m%tc*e>e+_IEGyj3BY2F8f*?DOTT+a^ncmu#lF_|e|^#wTEPuz?*2 z->sy&v)Z2EWPlG%ew10u$iMx9F%N*@lz+{_ceM?Co5qSBTZ^GGI(SF6gAFZKdwMdE z!71DAeV}+q;6>((-Vxo)6mkp(Rz55F^Rf?kVeGta8}9d=-Z>|V3pVFP81ynW-yUDT zyrWWR>QTWN9a%E6r^xqt91o2Jnf<~m_X@lY9vC=2j;-|&WA0ZPe4?}d)-PL4Tz_p5 zRE%P4ljVGHC#6i>^3eU7zB1yifO@HWJ&FA~+thsuE+9YTv-NjArzQwJYByj$R^V1O zse`3k*zP)SyidEgc5+^Ms%tOPK7y**_3d82ptY4jjN3vi1GbM>%SD2H@_GZ zBB73Wl4ConAQ)$a*AMud0m=BBJ{V06AmBXUTsw`QC=qIkWk`Vyj)oBkzJCRt3r)^S zSYt2B`;s8zQPN(e>e-oKA|BwrKOiY|vQ?^Mp7883H(jMBhQVk$6ydaHtzn^EPRnsE5(m+aA`bdl!s^)I)*QBBe_I;EMMXn#hT$ zeyR{W|2z@)Cw99+t;v|xOn-x;lLX%A81F3i(YYluS;i+awv%Qn%H!vnOiIGXu{*7i zlz1X(ZG6HKc0v;ucD6Wj#c+w3(tQ}nvq-g@6|*ZKLaUTi9|-?N?gt^|jSJQq`~Z3d zM&#y(&T#kbu0RM5wzN|~hOo>8djETYpd%>Tuu~l_-|t+lvp$`t4S!Ff=a?p7c4Y4* z_+qdMegHx;Pw~Pi5g!V@CUNP!AfgJ;9#Jbjj5zXwcSKc+?IMc(cR@C!@5N-W&7%d}-R`5r(B->;Ngasg9LxxUk#fW!a$Lt=B;@r*-k@C4(f zJ1Fwym7A4rb0<#%ohaSWC<;7k2kKWA&mkJ)JV;agUaia_Sn$V4lp@qv?j}5+SJO_3 zZ;S+oHhje@Uw<*S-@9lWkIOmLiJs)Oo%(5sA|Gp#;a@CbL(=mSf%f-+8z71E`sEZ!j3P%xdGJAS`balG5q2RAJ*PC zt>Q*pLNt{rCtx;2^)wPDtsduwH(7-rm_EFY7)R0ewC{dZ6p>-PapJy@KXom%4;suxd>Iy4w}&GfZVP=-{cZ-{BA)0{+ycd_q&!H6H} zeDh4-&E6r76bkI;Xi!Qs?u?7m}MqTF2ri^MA5K=0G2=Uq)joK~%~**DAkkH@a}# zu`~9eeuc#)d?~Pw3ocM^Y|1&lm;k|Xzv}{+^a_}pN)#%2!gu04UPRQosd;D!66MS$ z`z@mj^8^Ycwkvf9&oRA34t52kA0+r`r|kd3bg{e9FYv8iVKB*gvER!Dce2M02HgG_ zHh*yF4VsZ|;|~GU3G7&B%HU~Sv>3GNqpE%!Pt23_!dys;aUqnUtQ%{@+FG|)=w>`` z9N{tz|1%Kjs^s7Q!-7(9M6dUQ@yx#L9oX8$lH1FV9lMZ9=6Cezx+5_U3S)+|gg7oMO zMdBNDGP;q?hf+(6;gu6+dWkDL29`EdB~z?gi8cL+h~c06CYarcpg|Ga1tPAc9b^0Dw(+KHr*DGE&39%WYZ z4EAIYmk&l@8~JSAsM22SRoF#SGUvSAt}ww|{9Zd`tPCjyZ=ZrXwBrtIgCf;8-^57CgsFFyL)Bj~^Pr&R^RTEQ0lNUR=v&?$7!Bqpf1l6(k=Wx<@g5`E!j1 zBmRLcXqnn?Htq!qTY$aSL>_KdJqi?=!z6~Q6KpYhh4#t_2v?5%H2P$STx#ZgcS0Qm zDozc1LkeOvMy9KV%h5nB%RI_^hM>2O%SR@aVon!0GAAu)k#H7O7GqVd*NNR|JFtes zZy*88Osq2E{ zVRmV6a$gW3`Vbm8Uivsd@xfzBZE)mKb!BrPpKcHG$SzcQaOf3RMp1QXV`~_~=2LZY zW?>0q2pDK8Bk};GlRQ%?2)Ar#8yWWCACqlU7=Nx8Et27&e9ke8XI2_ z8aO=|rGWEEWaVjQX=QRa9r@=_VRB(@Wpn|g0chlDb1ruv9+{&qVc-QWXJvGBWq5Sc z9337XEG=IU5LZT201y~H2nqlb=0kOJbZ>1LnLtlY;0IJyRY^qBAR!_vRaRkab!A@= z6Mt7mQ)K}M{ZbxR?}B0JXl#;Tr0H+fss2~QcO1>Xc<@uY=ik+T{|`X zKrLzHW^!+B4T|p7LS7vnUtnWoWoBDfMhHjcNp56icoIbj4&+mCWn(E{W?}14QDT#3 zRyGc3mZff!AYm=&0DhzPsaddTd^lozT>2s6dRw)d4(oqm$rQFCEneL}7Gj78>>k)&|EU2X{knPfG@KXdM^# z55NYKELa{Lm*kkyCz%;q<#l$Npa}pNR-NRY(I=nw$#Q*upp$1(_eE}G0De}okDT(I>|!?PkbM%+73z z&677-BY!~8o=ed5zyU@rFyIF*G(tm0EjBPSF)c$yHbgZuH#jsxMMeOD<=KHBLGSOYXEuxlmMsz%>e5G2LUSqM}GlX0dWDB0hYD0>=W%0?h){0@niE0_y_*0}KN*12_X& z17ZVo19<~{1ET}21F{3R1G)pl1I`241M36$1Nj5~1Rw+{1Th3f1X=`N1ZV_l1c3yD z1b?ao(FE26;RN{v`vm|6Cj~AAGX*#WI|V`oPX${AWCdjfY6Xx5n+2x@xdpxj#|6j* z$_38__67t72?i7f83rN-BL*b~Dh5#oRt8}PW(IEta0YS)cLtsYqXw=9vj)5dy#~Jq z#0KjI@COnH9|t=JM+e}hOb1a1S_fMPf`12z2e1dV2iFJc2mlBd2rvjn2xAC+2#W}w z2%`w72&)LR2(<{d2)hWt2*U`>2+aue2=xf|2>S^D2?GfX2@MGj2@eSr2^$GQ2}cQB z33Lg034RHI35p4e35*Gm36=?&38D$33Can}3D61E3D^nY3E~Ok3Fry!3GfN|2_gCk m`w9FC0SX8TFA6jYI|^b7V+v#nX$o!%c?zHkqYAB)Nn0o~S4fut 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 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 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: